@spooky-sync/core 0.0.1-canary.260 → 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/types.d.ts CHANGED
@@ -299,6 +299,12 @@ interface LocalStore extends LocalCacheEngine {
299
299
  * client facade, so adding this needs no change on their side.
300
300
  */
301
301
  readonly storageHealth?: StorageHealth;
302
+ /**
303
+ * Delete a bucket's persisted store. Never the open one. OPTIONAL and
304
+ * best-effort: used to remove an impersonated user's data from the admin's
305
+ * device once the impersonation ends.
306
+ */
307
+ dropBucket?(bucketId: string): Promise<void>;
302
308
  /** Fires immediately with the current snapshot, then on every change.
303
309
  * Returns an unsubscribe function. */
304
310
  subscribeToStorageHealth?(cb: (health: StorageHealth) => void): () => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.260",
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.260",
67
- "@spooky-sync/ssp-wasm": "0.0.1-canary.260",
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",
@@ -5,7 +5,7 @@ import { fakeServices } from '../testing/services';
5
5
  import { buildEntry, buildOutboxItem, buildState } from '../testing/build';
6
6
  import * as R from '../state/reducers';
7
7
  import { defaultEnv } from '../query/env';
8
- import { bucketSwitch, rebindQueries } from './bucket-switch.saga';
8
+ import { bucketSwitch, dropImpersonatedBucket, rebindQueries } from './bucket-switch.saga';
9
9
 
10
10
  const env = defaultEnv({ tables: [] } as any);
11
11
  const base = (over: Partial<ReturnType<typeof buildState>> = {}) => ({ ...buildState(), pendingBucket: 'u2', bucketId: 'u1', ...over });
@@ -57,6 +57,7 @@ describe('bucketSwitch', () => {
57
57
  'auth.sessionAuthId',
58
58
  'auth.access',
59
59
  'ssp.setSessionAuth',
60
+ 'auth.consumeEndedImpersonation',
60
61
  'auth.token',
61
62
  'persistence.set',
62
63
  ]);
@@ -128,6 +129,34 @@ describe('bucketSwitch', () => {
128
129
  });
129
130
  });
130
131
 
132
+ describe('dropImpersonatedBucket', () => {
133
+ it('drops the bucket it just left when that was the impersonated user', async () => {
134
+ const svc = fakeServices({ 'auth.consumeEndedImpersonation': () => 'user:bob' });
135
+ await runPure(dropImpersonatedBucket('bob'), { state: buildState(), handlers: { service: svc.handler } });
136
+ expect(svc.calls).toEqual([
137
+ ['auth.consumeEndedImpersonation', []],
138
+ ['local.dropBucket', ['bob']],
139
+ ]);
140
+ });
141
+ it('leaves every other bucket alone', async () => {
142
+ for (const [ended, left] of [[null, 'bob'], ['user:bob', 'alice'], ['user:bob', 'anon']] as const) {
143
+ const svc = fakeServices({ 'auth.consumeEndedImpersonation': () => ended });
144
+ await runPure(dropImpersonatedBucket(left), { state: buildState(), handlers: { service: svc.handler } });
145
+ expect(svc.names()).toEqual(['auth.consumeEndedImpersonation']);
146
+ }
147
+ });
148
+ it('logs a failed delete instead of failing the switch', async () => {
149
+ const svc = fakeServices({
150
+ 'auth.consumeEndedImpersonation': () => 'user:bob',
151
+ 'local.dropBucket': async () => {
152
+ throw new Error('handles still open');
153
+ },
154
+ });
155
+ const out = await runPure(dropImpersonatedBucket('bob'), { state: buildState(), handlers: { service: svc.handler } });
156
+ expect(out.emitted.filter((e) => e.type === 'log')).toHaveLength(1);
157
+ });
158
+ });
159
+
131
160
  describe('rebindQueries', () => {
132
161
  it('re-seeds from the new store, rebuilds the local view (tolerating failures), emits authority flips', async () => {
133
162
  const s = R.compose(
@@ -5,7 +5,7 @@ import { fx } from '../kernel/effects';
5
5
  import type { ClientState, QueryEntry } from '../state/client-state';
6
6
  import { seedLifecycle, isAuthoritative } from '../state/lifecycle';
7
7
  import * as R from '../state/reducers';
8
- import { ANON_USER_ID } from '../modules/ref-tables';
8
+ import { ANON_USER_ID, bucketIdForUser } from '../modules/ref-tables';
9
9
  import type { SagaEnv } from '../query/env';
10
10
  import { queryHashInput } from '../query/hash';
11
11
  import { isResolvedBefore, parseViewRow } from '../query/membership';
@@ -62,6 +62,7 @@ export function* bucketSwitch(env: SagaEnv, target: string, release: (() => void
62
62
  } finally {
63
63
  gate();
64
64
  }
65
+ yield* dropImpersonatedBucket(current);
65
66
  const token = (yield fx.service('auth.token')) as string | null;
66
67
  if (token) {
67
68
  try {
@@ -82,6 +83,21 @@ export function* bucketSwitch(env: SagaEnv, target: string, release: (() => void
82
83
  yield fx.dispatch({ type: 'Drain' });
83
84
  }
84
85
 
86
+ /**
87
+ * An impersonation just ended and this switch left its bucket: delete that
88
+ * user's local data from the admin's device. Best-effort; a failure only
89
+ * leaves rows the server already let this admin read.
90
+ */
91
+ export function* dropImpersonatedBucket(left: string): Saga<void> {
92
+ const ended = (yield fx.service('auth.consumeEndedImpersonation')) as string | null;
93
+ if (!ended || left === ANON_USER_ID || bucketIdForUser(ended) !== left) return;
94
+ try {
95
+ yield fx.service('local.dropBucket', left);
96
+ } catch (error) {
97
+ yield fx.emit({ type: 'log', level: 'warn', message: 'could not delete the impersonated bucket', data: { bucket: left, error } });
98
+ }
99
+ }
100
+
85
101
  /** Re-seed every active query from the new store and rebuild its SSP view. */
86
102
  export function* rebindQueries(): Saga<void> {
87
103
  const [entries, sessionId] = (yield fx.state.read((s) => [[...s.queries.values()], s.sessionId])) as [QueryEntry[], string | null];
@@ -249,6 +249,9 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
249
249
  'local.beginSwitch': () => s.local.beginSwitch(),
250
250
  'local.currentBucketId': () => s.local.currentBucketId,
251
251
  'local.usesSurqlSchema': () => s.local.usesSurqlSchema,
252
+ 'local.dropBucket': async (bucketId) => {
253
+ await s.local.dropBucket?.(bucketId);
254
+ },
252
255
  'migrator.provision': () => s.migrator.provision(config.schemaSurql),
253
256
  'blobs.start': async (bucketId) => {
254
257
  s.blobs.setMaxBytes(await resolveBlobBudget(config.blobCache?.maxBytes));
@@ -278,6 +281,7 @@ export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S
278
281
  'auth.access': () => s.auth.access,
279
282
  'auth.token': () => s.auth.token,
280
283
  'auth.currentUser': () => (s.auth.currentUser as Record<string, unknown> | null) ?? null,
284
+ 'auth.consumeEndedImpersonation': () => s.auth.consumeEndedImpersonation(),
281
285
  'remote.connect': () => s.remote.connect(),
282
286
  'remote.releaseViews': (ids) => {
283
287
  const list = ids
@@ -54,6 +54,7 @@ import { DEGRADE_AFTER_FAILURES, OUTBOX_BATCH_SIZE, PUSH_TIMEOUT_MS } from '../k
54
54
  import { Runtime } from './runtime';
55
55
  import { createAdapters, createServices, createTabsCoordinator, type ServiceHost, type Services } from './services';
56
56
  import { BucketHandle } from './bucket-handle';
57
+ import { ImpersonationBanner } from '../modules/auth/impersonation-banner';
57
58
  import type { LeaderSyncHub, SyncForwarder } from '../services/tabs/coordinator';
58
59
 
59
60
  const UNKNOWN_STORAGE_HEALTH: StorageHealth = Object.freeze({ status: 'unknown', fallback: false });
@@ -78,6 +79,8 @@ export class Sp00kyClient<S extends SchemaStructure> {
78
79
  private readonly runtime: Runtime;
79
80
  private readonly featureFlags: FeatureFlagModule<S>;
80
81
  private readonly appReleases: AppReleaseModule<S>;
82
+ /** Teardown for subscriptions the client itself owns. */
83
+ private readonly disposers: Array<() => void> = [];
81
84
  private readonly devTools: DevToolsService;
82
85
  private hub: LeaderSyncHub | null = null;
83
86
  private forwarder: SyncForwarder | null = null;
@@ -174,6 +177,10 @@ export class Sp00kyClient<S extends SchemaStructure> {
174
177
  s.streamProcessor.addReceiver(receiver);
175
178
  s.connectionSupervisor.subscribe((state) => void this.runtime.dispatch({ type: 'ConnectionChanged', state }));
176
179
  s.auth.subscribe((userId) => void this.runtime.dispatch({ type: 'AuthFlip', userId }));
180
+ // The impersonation banner is not optional: whoever is looking at the page
181
+ // must be able to tell, and leave, whatever the app renders.
182
+ const banner = new ImpersonationBanner(() => s.auth.stopImpersonating());
183
+ this.disposers.push(s.auth.subscribeImpersonation((info) => banner.update(info)), () => banner.unmount());
177
184
  this.runtime.on('tabs:broadcast', (e) => {
178
185
  if (e.type !== 'tabs:broadcast') return;
179
186
  const msg = e.message as { type: string; records?: unknown[]; mutationId?: string };
@@ -224,6 +231,7 @@ export class Sp00kyClient<S extends SchemaStructure> {
224
231
  async close(): Promise<void> {
225
232
  const s = this.services;
226
233
  s.connectionSupervisor.dispose();
234
+ for (const dispose of this.disposers.splice(0)) dispose();
227
235
  this.runtime.dispose();
228
236
  await this.featureFlags.closeAll();
229
237
  await this.appReleases.closeAll();
@@ -75,6 +75,7 @@ export interface ServiceCalls {
75
75
  'local.beginSwitch': () => () => void;
76
76
  'local.currentBucketId': () => string;
77
77
  'local.usesSurqlSchema': () => boolean;
78
+ 'local.dropBucket': (bucketId: string) => Promise<void>;
78
79
  'migrator.provision': () => Promise<void>;
79
80
  'blobs.start': (bucketId: string) => Promise<void>;
80
81
  'blobs.setNamespace': (bucketId: string) => Promise<void>;
@@ -91,6 +92,7 @@ export interface ServiceCalls {
91
92
  'auth.access': () => string | null;
92
93
  'auth.token': () => string | null;
93
94
  'auth.currentUser': () => Record<string, unknown> | null;
95
+ 'auth.consumeEndedImpersonation': () => string | null;
94
96
  'remote.connect': () => Promise<void>;
95
97
  'remote.releaseViews': (ids: unknown[]) => void;
96
98
  'supervisor.start': () => void;
@@ -1,8 +1,10 @@
1
1
  import type { EventDefinition, EventSystem } from '../../../events/index';
2
2
  import { createEventSystem } from '../../../events/index';
3
+ import type { ImpersonationInfo } from '../impersonation';
3
4
 
4
5
  export const AuthEventTypes = {
5
6
  AuthStateChanged: 'AUTH_STATE_CHANGED',
7
+ ImpersonationChanged: 'IMPERSONATION_CHANGED',
6
8
  } as const;
7
9
 
8
10
  export type AuthEventTypeMap = {
@@ -10,10 +12,14 @@ export type AuthEventTypeMap = {
10
12
  typeof AuthEventTypes.AuthStateChanged,
11
13
  string | null
12
14
  >;
15
+ [AuthEventTypes.ImpersonationChanged]: EventDefinition<
16
+ typeof AuthEventTypes.ImpersonationChanged,
17
+ ImpersonationInfo | null
18
+ >;
13
19
  };
14
20
 
15
21
  export type AuthEventSystem = EventSystem<AuthEventTypeMap>;
16
22
 
17
23
  export function createAuthEventSystem(): AuthEventSystem {
18
- return createEventSystem([AuthEventTypes.AuthStateChanged]);
24
+ return createEventSystem([AuthEventTypes.AuthStateChanged, AuthEventTypes.ImpersonationChanged]);
19
25
  }
@@ -0,0 +1,273 @@
1
+ import type { ImpersonationInfo } from './impersonation';
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;
7
+
8
+ /** Radius of the page's new top corners. */
9
+ const PAGE_RADIUS_PX = 14;
10
+
11
+ const HOST_TAG = 'sp00ky-impersonation-banner';
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
+
24
+ // The shadow root keeps app CSS away from the bar, but the host element itself
25
+ // is in the page and matches the page's selectors (`div { display: none }`).
26
+ // An `!important` rule on `:host` wins over the page's own `!important`
27
+ // (inner tree context), which keeps the host rendered whatever the app says.
28
+ const STYLE = `
29
+ :host {
30
+ all: initial !important;
31
+ display: block !important;
32
+ visibility: visible !important;
33
+ opacity: 1 !important;
34
+ position: static !important;
35
+ transform: none !important;
36
+ clip-path: none !important;
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
+ }
65
+ .bar {
66
+ position: fixed; inset: 0 0 auto 0; height: ${BANNER_HEIGHT_PX}px; z-index: 2147483647;
67
+ 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;
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; }
78
+ .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
79
+ .text b { color: #fff; font-weight: 800; }
80
+ button {
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);
85
+ }
86
+ button:hover { background: #fff; }
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
+ }
92
+ `;
93
+
94
+ /**
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`.
106
+ */
107
+ export class ImpersonationBanner {
108
+ private host: HTMLElement | null = null;
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;
112
+
113
+ constructor(private onStop: () => Promise<void>) {}
114
+
115
+ static supported(): boolean {
116
+ return typeof document !== 'undefined' && typeof document.createElement === 'function';
117
+ }
118
+
119
+ update(info: ImpersonationInfo | null): void {
120
+ if (!ImpersonationBanner.supported()) return;
121
+ if (!info) {
122
+ this.unmount();
123
+ return;
124
+ }
125
+ this.mount();
126
+ 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.`;
133
+ }
134
+
135
+ unmount(): void {
136
+ this.host?.remove();
137
+ this.host = this.text = null;
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;
143
+ }
144
+ document.documentElement?.style.setProperty('--sp00ky-impersonation-banner-height', '0px');
145
+ }
146
+
147
+ private mount(): void {
148
+ if (this.host?.isConnected) return;
149
+ const host = document.createElement(HOST_TAG);
150
+ const root = host.attachShadow({ mode: 'closed' });
151
+ 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
+ }
167
+
168
+ const bar = document.createElement('div');
169
+ bar.className = 'bar';
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 = '⚠';
176
+ const text = document.createElement('span');
177
+ text.className = 'text';
178
+ const button = document.createElement('button');
179
+ button.type = 'button';
180
+ button.textContent = 'Stop';
181
+ button.addEventListener('click', () => {
182
+ button.disabled = true;
183
+ this.onStop().finally(() => {
184
+ button.disabled = false;
185
+ });
186
+ });
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();
196
+ this.host = host;
197
+ this.text = text;
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;
267
+ }
268
+
269
+ function bold(value: string): HTMLElement {
270
+ const b = document.createElement('b');
271
+ b.textContent = value;
272
+ return b;
273
+ }