@spooky-sync/core 0.0.1-canary.31 → 0.0.1-canary.310

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.
Files changed (257) hide show
  1. package/AGENTS.md +81 -0
  2. package/README.md +56 -16
  3. package/dist/index.d.ts +2026 -137
  4. package/dist/index.js +14956 -2434
  5. package/dist/live.d.ts +141 -0
  6. package/dist/live.js +709 -0
  7. package/dist/otel/index.d.ts +1 -1
  8. package/dist/otel/index.js +3 -3
  9. package/dist/pure.d.ts +329 -0
  10. package/dist/pure.js +24 -0
  11. package/dist/sqlite-open.js +358 -0
  12. package/dist/sqlite-worker.d.ts +1 -0
  13. package/dist/sqlite-worker.js +439 -0
  14. package/dist/sw.d.ts +207 -0
  15. package/dist/sw.js +393 -0
  16. package/dist/tabs-broker-worker.d.ts +8 -0
  17. package/dist/tabs-broker-worker.js +472 -0
  18. package/dist/types.d.ts +1765 -38
  19. package/dist/worker-edges.js +423 -0
  20. package/dist/worker-impersonation.js +60 -0
  21. package/dist/worker-ref-tables.d.ts +46 -0
  22. package/dist/worker-types.d.ts +112 -0
  23. package/dist/worker-types.js +73 -0
  24. package/package.json +27 -7
  25. package/scripts/check-broker-bundle.mjs +33 -0
  26. package/scripts/check-worker-entries.mjs +172 -0
  27. package/skills/sp00ky-core/SKILL.md +31 -6
  28. package/skills/sp00ky-core/references/config.md +6 -0
  29. package/src/boot/auth-flip.saga.test.ts +135 -0
  30. package/src/boot/auth-flip.saga.ts +77 -0
  31. package/src/boot/boot.saga.test.ts +158 -0
  32. package/src/boot/boot.saga.ts +131 -0
  33. package/src/boot/bucket-switch.saga.test.ts +187 -0
  34. package/src/boot/bucket-switch.saga.ts +133 -0
  35. package/src/boot/preload.saga.test.ts +51 -0
  36. package/src/boot/preload.saga.ts +31 -0
  37. package/src/bucket-blurhash.test.ts +148 -0
  38. package/src/build-globals.d.ts +12 -0
  39. package/src/client/bucket-handle.test.ts +117 -0
  40. package/src/client/bucket-handle.ts +227 -0
  41. package/src/client/router.test.ts +58 -0
  42. package/src/client/router.ts +100 -0
  43. package/src/client/runtime.test.ts +195 -0
  44. package/src/client/runtime.ts +296 -0
  45. package/src/client/services.ts +370 -0
  46. package/src/client/sp00ky-client.test.ts +303 -0
  47. package/src/client/sp00ky-client.ts +586 -0
  48. package/src/events/events.test.ts +2 -1
  49. package/src/events/index.ts +3 -0
  50. package/src/index.ts +69 -1
  51. package/src/kernel/constants.test.ts +19 -0
  52. package/src/kernel/constants.ts +80 -0
  53. package/src/kernel/effects.test.ts +40 -0
  54. package/src/kernel/effects.ts +161 -0
  55. package/src/kernel/events.ts +57 -0
  56. package/src/kernel/interpreter.test.ts +116 -0
  57. package/src/kernel/interpreter.ts +148 -0
  58. package/src/kernel/saga.test.ts +89 -0
  59. package/src/kernel/saga.ts +73 -0
  60. package/src/live/index.test.ts +478 -0
  61. package/src/live/index.ts +1013 -0
  62. package/src/modules/app-release/index.test.ts +125 -0
  63. package/src/modules/app-release/index.ts +209 -0
  64. package/src/modules/auth/auth.local-first.test.ts +141 -0
  65. package/src/modules/auth/events/index.ts +9 -2
  66. package/src/modules/auth/impersonation-banner.ts +443 -0
  67. package/src/modules/auth/impersonation.test.ts +355 -0
  68. package/src/modules/auth/impersonation.ts +179 -0
  69. package/src/modules/auth/index.ts +386 -17
  70. package/src/modules/crdt/crdt-field.ts +294 -0
  71. package/src/modules/crdt/crdt-hydration.test.ts +210 -0
  72. package/src/modules/crdt/crdt-reconnect.test.ts +195 -0
  73. package/src/modules/crdt/index.ts +463 -0
  74. package/src/modules/crdt/loro-loader.ts +25 -0
  75. package/src/modules/devtools/flags.ts +349 -0
  76. package/src/modules/devtools/index.ts +746 -95
  77. package/src/modules/devtools/mutations.test.ts +92 -0
  78. package/src/modules/devtools/mutations.ts +160 -0
  79. package/src/modules/devtools/notify-throttle.test.ts +166 -0
  80. package/src/modules/devtools/state-shape.test.ts +263 -0
  81. package/src/modules/devtools/storage-info.test.ts +79 -0
  82. package/src/modules/devtools/storage-info.ts +168 -0
  83. package/src/modules/devtools/versions.test.ts +74 -0
  84. package/src/modules/devtools/versions.ts +110 -0
  85. package/src/modules/feature-flag/index.test.ts +251 -0
  86. package/src/modules/feature-flag/index.ts +316 -0
  87. package/src/modules/ref-tables.test.ts +91 -0
  88. package/src/modules/ref-tables.ts +88 -0
  89. package/src/modules/web-push/index.test.ts +824 -0
  90. package/src/modules/web-push/index.ts +1027 -0
  91. package/src/mutation/jobs.test.ts +25 -0
  92. package/src/mutation/jobs.ts +41 -0
  93. package/src/mutation/mutation-id.test.ts +37 -0
  94. package/src/mutation/mutation-id.ts +35 -0
  95. package/src/mutation/pending-writes.integration.test.ts +175 -0
  96. package/src/mutation/push.saga.test.ts +285 -0
  97. package/src/mutation/push.saga.ts +217 -0
  98. package/src/mutation/rows.test.ts +231 -0
  99. package/src/mutation/rows.ts +427 -0
  100. package/src/mutation/tray.saga.test.ts +63 -0
  101. package/src/mutation/tray.saga.ts +48 -0
  102. package/src/mutation/write.saga.test.ts +316 -0
  103. package/src/mutation/write.saga.ts +268 -0
  104. package/src/otel/index.ts +10 -7
  105. package/src/pure.ts +65 -0
  106. package/src/push/types.ts +174 -0
  107. package/src/query/env.test.ts +23 -0
  108. package/src/query/env.ts +56 -0
  109. package/src/query/fetch.saga.test.ts +199 -0
  110. package/src/query/fetch.saga.ts +120 -0
  111. package/src/query/hash.test.ts +14 -0
  112. package/src/query/hash.ts +18 -0
  113. package/src/query/id-set-plan.test.ts +132 -0
  114. package/src/query/lifecycle.saga.test.ts +274 -0
  115. package/src/query/lifecycle.saga.ts +159 -0
  116. package/src/query/materialize.saga.test.ts +115 -0
  117. package/src/query/materialize.saga.ts +84 -0
  118. package/src/query/materialize.test.ts +58 -0
  119. package/src/query/materialize.ts +57 -0
  120. package/src/query/membership.saga.test.ts +350 -0
  121. package/src/query/membership.saga.ts +278 -0
  122. package/src/query/membership.test.ts +118 -0
  123. package/src/query/membership.ts +201 -0
  124. package/src/query/register.saga.test.ts +293 -0
  125. package/src/query/register.saga.ts +226 -0
  126. package/src/query/render-set.test.ts +47 -0
  127. package/src/query/render-set.ts +71 -0
  128. package/src/query/sql.test.ts +63 -0
  129. package/src/query/sql.ts +93 -0
  130. package/src/query/window-query.test.ts +70 -0
  131. package/src/query/window-query.ts +195 -0
  132. package/src/services/blobs/blob-cache.test.ts +359 -0
  133. package/src/services/blobs/blob-cache.ts +603 -0
  134. package/src/services/blobs/blob-manifest.ts +227 -0
  135. package/src/services/blobs/blob-store.test.ts +77 -0
  136. package/src/services/blobs/blob-store.ts +359 -0
  137. package/src/services/blobs/blob.fixture.ts +90 -0
  138. package/src/services/blobs/index.ts +70 -0
  139. package/src/services/database/cache-engine.ts +199 -0
  140. package/src/services/database/connection-supervisor.test.ts +318 -0
  141. package/src/services/database/connection-supervisor.ts +422 -0
  142. package/src/services/database/database.query-timeout.test.ts +140 -0
  143. package/src/services/database/database.responses.test.ts +43 -0
  144. package/src/services/database/database.ts +146 -61
  145. package/src/services/database/engine-factory.ts +33 -0
  146. package/src/services/database/errors.ts +34 -0
  147. package/src/services/database/events/index.ts +2 -1
  148. package/src/services/database/index.ts +7 -0
  149. package/src/services/database/landed-body-clear.integration.test.ts +236 -0
  150. package/src/services/database/local-migrator.integration.test.ts +110 -0
  151. package/src/services/database/local-migrator.ts +59 -16
  152. package/src/services/database/local.test.ts +64 -0
  153. package/src/services/database/local.ts +487 -63
  154. package/src/services/database/plan-render.test.ts +159 -0
  155. package/src/services/database/plan-render.ts +108 -0
  156. package/src/services/database/relation-resolver.test.ts +467 -0
  157. package/src/services/database/relation-resolver.ts +0 -0
  158. package/src/services/database/remote.ts +213 -6
  159. package/src/services/database/sqlite-cache-engine.test.ts +617 -0
  160. package/src/services/database/sqlite-cache-engine.timeout.test.ts +61 -0
  161. package/src/services/database/sqlite-cache-engine.ts +1372 -0
  162. package/src/services/database/sqlite-devtools-queries.integration.test.ts +143 -0
  163. package/src/services/database/sqlite-devtools-queries.test.ts +154 -0
  164. package/src/services/database/sqlite-lock-verify.test.ts +33 -0
  165. package/src/services/database/sqlite-lock-verify.ts +45 -0
  166. package/src/services/database/sqlite-open.test.ts +150 -0
  167. package/src/services/database/sqlite-open.ts +164 -0
  168. package/src/services/database/sqlite-plan-sql.test.ts +139 -0
  169. package/src/services/database/sqlite-plan-sql.ts +179 -0
  170. package/src/services/database/sqlite-projection.test.ts +121 -0
  171. package/src/services/database/sqlite-select.integration.test.ts +185 -0
  172. package/src/services/database/sqlite-select.test.ts +246 -0
  173. package/src/services/database/sqlite-select.ts +131 -0
  174. package/src/services/database/sqlite-transport.fixture.ts +30 -0
  175. package/src/services/database/sqlite-transport.ts +224 -0
  176. package/src/services/database/sqlite-worker.ts +437 -0
  177. package/src/services/database/surql-translate.ts +416 -0
  178. package/src/services/database/surreal-cache-engine.ts +161 -0
  179. package/src/services/logger/index.test.ts +132 -0
  180. package/src/services/logger/index.ts +189 -7
  181. package/src/services/persistence/localstorage.ts +2 -2
  182. package/src/services/persistence/resilient.ts +10 -3
  183. package/src/services/persistence/surrealdb.ts +4 -4
  184. package/src/services/stream-processor/index.ts +779 -67
  185. package/src/services/stream-processor/permissions.test.ts +47 -0
  186. package/src/services/stream-processor/permissions.ts +53 -0
  187. package/src/services/stream-processor/stream-processor.batch.test.ts +186 -0
  188. package/src/services/stream-processor/stream-processor.prime.test.ts +198 -0
  189. package/src/services/stream-processor/stream-processor.reset.test.ts +226 -0
  190. package/src/services/stream-processor/stream-processor.test.ts +1 -1
  191. package/src/services/stream-processor/wasm-types.ts +58 -2
  192. package/src/services/tabs/broker-client.ts +283 -0
  193. package/src/services/tabs/broker.test.ts +327 -0
  194. package/src/services/tabs/coordinator.test.ts +386 -0
  195. package/src/services/tabs/coordinator.ts +638 -0
  196. package/src/services/tabs/fake-ports.fixture.ts +112 -0
  197. package/src/services/tabs/leader-locks.ts +75 -0
  198. package/src/services/tabs/protocol.ts +262 -0
  199. package/src/services/tabs/support.ts +36 -0
  200. package/src/services/tabs/tabs-broker-worker.ts +640 -0
  201. package/src/sp00ky.ts +5 -395
  202. package/src/state/client-state.ts +200 -0
  203. package/src/state/lifecycle.test.ts +118 -0
  204. package/src/state/lifecycle.ts +86 -0
  205. package/src/state/reducers.test.ts +304 -0
  206. package/src/state/reducers.ts +526 -0
  207. package/src/state/selectors.test.ts +167 -0
  208. package/src/state/selectors.ts +216 -0
  209. package/src/sw/index.test.ts +582 -0
  210. package/src/sw/index.ts +607 -0
  211. package/src/sw/store.ts +96 -0
  212. package/src/sync/connection.saga.test.ts +86 -0
  213. package/src/sync/connection.saga.ts +85 -0
  214. package/src/sync/live.saga.test.ts +178 -0
  215. package/src/sync/live.saga.ts +107 -0
  216. package/src/sync/policy.health.test.ts +56 -0
  217. package/src/sync/policy.test.ts +659 -0
  218. package/src/sync/policy.ts +502 -0
  219. package/src/sync/poll.saga.test.ts +51 -0
  220. package/src/sync/poll.saga.ts +36 -0
  221. package/src/sync/tabs.saga.test.ts +78 -0
  222. package/src/sync/tabs.saga.ts +96 -0
  223. package/src/testing/adapters.ts +133 -0
  224. package/src/testing/build.ts +63 -0
  225. package/src/testing/fake-services.ts +48 -0
  226. package/src/testing/run-pure.test.ts +92 -0
  227. package/src/testing/run-pure.ts +128 -0
  228. package/src/testing/services.ts +17 -0
  229. package/src/types.ts +624 -10
  230. package/src/utils/blurhash.ts +90 -0
  231. package/src/utils/edges.ts +39 -0
  232. package/src/utils/error-classification.test.ts +54 -0
  233. package/src/utils/error-classification.ts +25 -0
  234. package/src/utils/index.ts +78 -12
  235. package/src/utils/parser.test.ts +67 -102
  236. package/src/utils/parser.ts +64 -2
  237. package/src/utils/semver.test.ts +32 -0
  238. package/src/utils/semver.ts +30 -0
  239. package/src/utils/sha256.ts +15 -0
  240. package/src/utils/surql.ts +30 -18
  241. package/src/utils/utils.test.ts +3 -2
  242. package/tsdown.config.ts +117 -3
  243. package/vitest.config.ts +48 -0
  244. package/src/modules/cache/index.ts +0 -241
  245. package/src/modules/cache/types.ts +0 -19
  246. package/src/modules/data/data.test.ts +0 -58
  247. package/src/modules/data/index.ts +0 -879
  248. package/src/modules/sync/engine.ts +0 -173
  249. package/src/modules/sync/events/index.ts +0 -77
  250. package/src/modules/sync/index.ts +0 -3
  251. package/src/modules/sync/queue/index.ts +0 -2
  252. package/src/modules/sync/queue/queue-down.ts +0 -89
  253. package/src/modules/sync/queue/queue-up.ts +0 -223
  254. package/src/modules/sync/scheduler.ts +0 -84
  255. package/src/modules/sync/sync.ts +0 -421
  256. package/src/modules/sync/utils.test.ts +0 -311
  257. package/src/modules/sync/utils.ts +0 -167
@@ -0,0 +1,443 @@
1
+ import type { ImpersonationInfo } from './impersonation';
2
+
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;
8
+
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%)';
20
+
21
+ const HOST_TAG = 'sp00ky-impersonation-banner';
22
+
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
+ }
96
+
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 };
118
+ }
119
+
120
+ // The shadow root keeps app CSS away from the bar, but the host element itself
121
+ // is in the page and matches the page's selectors (`div { display: none }`).
122
+ // An `!important` rule on `:host` wins over the page's own `!important`
123
+ // (inner tree context), which keeps the host rendered whatever the app says.
124
+ export function bannerCss(t: ResolvedTheme): string {
125
+ return `
126
+ :host {
127
+ all: initial !important;
128
+ display: block !important;
129
+ visibility: visible !important;
130
+ opacity: 1 !important;
131
+ position: static !important;
132
+ transform: none !important;
133
+ clip-path: none !important;
134
+ }
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. */
139
+ .bar {
140
+ position: fixed; inset: 0 0 auto 0; height: ${t.heightPx}px; z-index: 2147483647;
141
+ display: flex; align-items: center; justify-content: center; gap: 10px;
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;
148
+ }
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;
154
+ }
155
+ .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
156
+ .text b { font-weight: 800; }
157
+ button {
158
+ flex: none; cursor: pointer; border: 0; border-radius: 999px;
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);
162
+ }
163
+ button:hover { filter: brightness(1.15); }
164
+ button:disabled { opacity: .6; cursor: progress; }
165
+ @media (max-width: 520px) {
166
+ .bar { gap: 7px; padding: 0 8px; font-size: 11.5px; }
167
+ .sign { display: none; }
168
+ }
169
+ `;
170
+ }
171
+
172
+ /**
173
+ * The "you are impersonating" warning.
174
+ *
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.
186
+ *
187
+ * Every piece of text goes through `textContent`.
188
+ */
189
+ export class ImpersonationBanner {
190
+ private host: HTMLElement | null = null;
191
+ private text: HTMLElement | null = null;
192
+ /** `style` attributes as the page had them before the frame. */
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;
202
+
203
+ constructor(
204
+ private onStop: () => Promise<void>,
205
+ config: ImpersonationBannerConfig = {}
206
+ ) {
207
+ this.mode = config.mode ?? 'default';
208
+ this.theme = resolveTheme(config.theme);
209
+ }
210
+
211
+ static supported(): boolean {
212
+ return typeof document !== 'undefined' && typeof document.createElement === 'function';
213
+ }
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
+
226
+ update(info: ImpersonationInfo | null): void {
227
+ if (!ImpersonationBanner.supported()) return;
228
+ if (!info) {
229
+ this.acknowledged = false;
230
+ this.unmount();
231
+ return;
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 {
267
+ this.mount();
268
+ const label = this.theme.label(info);
269
+ const text = this.text!;
270
+ text.textContent = '';
271
+ text.append(...labelNodes(label));
272
+ text.title = `${label.replace(/\*\*/g, '')}. Every write is audited.`;
273
+ }
274
+
275
+ /** Remove the bar and put the page back exactly as it was. */
276
+ private teardown(): void {
277
+ this.host?.remove();
278
+ this.host = this.text = null;
279
+ if (typeof document === 'undefined') return;
280
+ if (this.onResize) {
281
+ window.removeEventListener('resize', this.onResize);
282
+ this.onResize = null;
283
+ }
284
+ if (this.saved) {
285
+ restoreStyle(document.documentElement, this.saved.html);
286
+ restoreStyle(document.body, this.saved.body);
287
+ this.saved = null;
288
+ }
289
+ document.documentElement?.style.setProperty('--sp00ky-impersonation-banner-height', '0px');
290
+ }
291
+
292
+ private clearFallback(): void {
293
+ if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
294
+ this.fallbackTimer = null;
295
+ }
296
+
297
+ private mount(): void {
298
+ if (this.host?.isConnected) return;
299
+ const host = document.createElement(HOST_TAG);
300
+ const root = host.attachShadow({ mode: 'closed' });
301
+ const style = document.createElement('style');
302
+ style.textContent = bannerCss(this.theme);
303
+
304
+ const bar = document.createElement('div');
305
+ bar.className = 'bar';
306
+ bar.setAttribute('role', 'alert');
307
+ const sign = document.createElement('span');
308
+ sign.className = 'sign';
309
+ sign.textContent = '!';
310
+ sign.setAttribute('aria-hidden', 'true');
311
+ const text = document.createElement('span');
312
+ text.className = 'text';
313
+ const button = document.createElement('button');
314
+ button.type = 'button';
315
+ button.textContent = this.theme.stopLabel;
316
+ button.addEventListener('click', () => {
317
+ button.disabled = true;
318
+ this.onStop().finally(() => {
319
+ button.disabled = false;
320
+ });
321
+ });
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.
326
+ document.documentElement.appendChild(host);
327
+
328
+ this.frame();
329
+ this.host = host;
330
+ this.text = text;
331
+ }
332
+
333
+ /**
334
+ * Turn `<body>` into the framed sheet.
335
+ *
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.
342
+ */
343
+ private frame(): void {
344
+ const root = document.documentElement;
345
+ const body = document.body;
346
+ if (!root || !body) return;
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;
352
+
353
+ this.saved = { html: root.getAttribute('style'), body: body.getAttribute('style') };
354
+ const surface = resolveSurface(root, body);
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);
387
+ }
388
+ }
389
+
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. */
392
+ function restoreStyle(el: HTMLElement | null, saved: string | null): void {
393
+ if (!el) return;
394
+ if (saved) el.setAttribute('style', saved);
395
+ else el.removeAttribute('style');
396
+ }
397
+
398
+ /**
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.
403
+ */
404
+ function resolveSurface(root: HTMLElement, body: HTMLElement): string {
405
+ for (const el of [body, root]) {
406
+ const color = getComputedStyle(el).backgroundColor;
407
+ if (color && !isTransparent(color)) return color;
408
+ }
409
+ const dark =
410
+ typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
411
+ return dark ? '#111111' : '#ffffff';
412
+ }
413
+
414
+ function isTransparent(color: string): boolean {
415
+ if (color === 'transparent') return true;
416
+ const parts = color.match(/[\d.]+/g);
417
+ return !!parts && parts.length === 4 && Number(parts[3]) === 0;
418
+ }
419
+
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 !== '');
440
+ }
441
+
442
+ /** Exported for unit tests only. */
443
+ export const __test = { labelSegments, resolveTheme };