@swifttui/web 0.0.6

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 (39) hide show
  1. package/AGENTS.md +52 -0
  2. package/README.md +116 -0
  3. package/cli.ts +168 -0
  4. package/index.html +50 -0
  5. package/index.ts +8 -0
  6. package/manifest.ts +1 -0
  7. package/package.json +33 -0
  8. package/src/AccessibilityTree.ts +262 -0
  9. package/src/BoxDrawingRenderer.ts +585 -0
  10. package/src/PublicEntrypointBoundary.test.ts +20 -0
  11. package/src/WebHostApp.test.ts +222 -0
  12. package/src/WebHostApp.ts +269 -0
  13. package/src/WebHostSceneManifest.test.ts +38 -0
  14. package/src/WebHostSceneManifest.ts +156 -0
  15. package/src/WebHostSceneRuntime.test.ts +1752 -0
  16. package/src/WebHostSceneRuntime.ts +955 -0
  17. package/src/WebHostSurfaceTransport.test.ts +362 -0
  18. package/src/WebHostSurfaceTransport.ts +648 -0
  19. package/src/WebHostTerminalStyle.test.ts +123 -0
  20. package/src/WebHostTerminalStyle.ts +471 -0
  21. package/src/WebHostTestFixtures.ts +10 -0
  22. package/src/WebSocketSceneBridge.test.ts +198 -0
  23. package/src/WebSocketSceneBridge.ts +233 -0
  24. package/src/browser.ts +59 -0
  25. package/src/wasi/BrowserWASIBridge.test.ts +168 -0
  26. package/src/wasi/BrowserWASIBridge.ts +167 -0
  27. package/src/wasi/SharedInputQueue.test.ts +146 -0
  28. package/src/wasi/SharedInputQueue.ts +199 -0
  29. package/src/wasi/StdIOPipe.ts +72 -0
  30. package/src/wasi/WasiPollScheduler.test.ts +176 -0
  31. package/src/wasi/WasiPollScheduler.ts +305 -0
  32. package/src/wasi/WasmSceneRuntime.ts +205 -0
  33. package/src/wasi/WasmSceneWorker.ts +182 -0
  34. package/style.css +15 -0
  35. package/testing.ts +1 -0
  36. package/tsconfig.json +29 -0
  37. package/wasi-worker.ts +1 -0
  38. package/wasi.ts +4 -0
  39. package/websocket.ts +1 -0
@@ -0,0 +1,123 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ import {
5
+ decodeWebHostTerminalRenderStyleBase64,
6
+ encodeWebHostTerminalRenderStyleBase64,
7
+ normalizeWebHostTerminalStyle,
8
+ resolveWebHostTerminalRenderStyle,
9
+ webTUITerminalBackgroundColor,
10
+ } from "./WebHostTerminalStyle.ts";
11
+
12
+ test("terminal style normalization fills default palette and theme", () => {
13
+ const style = normalizeWebHostTerminalStyle({
14
+ fontSize: 16,
15
+ cursorBlink: true,
16
+ });
17
+
18
+ expect(style.fontSize).toBe(16);
19
+ expect(style.cursorBlink).toBe(true);
20
+ expect(style.fontFamily.length).toBeGreaterThan(0);
21
+ expect(style.theme.background).toBe("#1e222a");
22
+ expect(style.palette.background).toBe("#1e222a");
23
+ });
24
+
25
+ test("terminal style resolves host-owned theme payloads", () => {
26
+ const style = {
27
+ palette: {
28
+ foreground: "#101010",
29
+ background: "#fafafa",
30
+ cursor: "#101010",
31
+ selectionBackground: "#d0e4ff",
32
+ selectionForeground: "#101010",
33
+ },
34
+ theme: {
35
+ foreground: "#111111",
36
+ background: "#fafafa",
37
+ tint: "#0f62fe",
38
+ separator: "#d0d0d0",
39
+ selection: "#d0e4ff",
40
+ placeholder: "#707070",
41
+ link: "#0f62fe",
42
+ fill: "#f4f4f4",
43
+ windowBackground: "#ffffff",
44
+ success: "#198038",
45
+ warning: "#b46e00",
46
+ danger: "#da1e28",
47
+ info: "#0f62fe",
48
+ muted: "#525252",
49
+ },
50
+ };
51
+
52
+ const resolved = resolveWebHostTerminalRenderStyle(style);
53
+ expect(resolved.appearance.foregroundColor).toBe("#111111");
54
+ expect(resolved.appearance.backgroundColor).toBe("#fafafa");
55
+ expect(resolved.theme?.warning).toBe("#b46e00");
56
+ expect(encodeWebHostTerminalRenderStyleBase64(style)).toBeDefined();
57
+ expect(
58
+ decodeWebHostTerminalRenderStyleBase64(
59
+ encodeWebHostTerminalRenderStyleBase64(style)
60
+ )?.appearance.backgroundColor
61
+ ).toBe("#fafafa");
62
+ });
63
+
64
+ test("terminal style maps to surface palette and translucent background", () => {
65
+ const style = {
66
+ backgroundOpacity: 0.5,
67
+ palette: {
68
+ foreground: "#ededed",
69
+ background: "#202020",
70
+ cursor: "#ffffff",
71
+ selectionBackground: "#264f78",
72
+ selectionForeground: "#ffffff",
73
+ },
74
+ theme: {
75
+ foreground: "#ededed",
76
+ background: "#202020",
77
+ tint: "#56b6c2",
78
+ separator: "#4c566a",
79
+ selection: "#2e3440",
80
+ placeholder: "#8c92ac",
81
+ link: "#5ba3ff",
82
+ fill: "#2b303b",
83
+ windowBackground: "#15181e",
84
+ success: "#61c67b",
85
+ warning: "#ebb33c",
86
+ danger: "#e05757",
87
+ info: "#56b6c2",
88
+ muted: "#8c92ac",
89
+ },
90
+ };
91
+
92
+ expect(normalizeWebHostTerminalStyle(style).palette.foreground).toBe("#ededed");
93
+ expect(normalizeWebHostTerminalStyle(style).palette.background).toBe("#202020");
94
+ expect(webTUITerminalBackgroundColor(style)).toBe("rgba(32, 32, 32, 0.5)");
95
+ expect(resolveWebHostTerminalRenderStyle(style).appearance.palette["0"]).toBe("#20242c");
96
+ });
97
+
98
+ test("shared default transport fixtures stay in sync with WebHost encoding", () => {
99
+ const fixture = transportFixture("terminal-render-style-default");
100
+
101
+ expect(JSON.stringify(resolveWebHostTerminalRenderStyle({}))).toBe(fixture.json);
102
+ expect(encodeWebHostTerminalRenderStyleBase64({})).toBe(fixture.base64);
103
+ expect(
104
+ JSON.stringify(decodeWebHostTerminalRenderStyleBase64(fixture.base64))
105
+ ).toBe(fixture.json);
106
+ });
107
+
108
+ function transportFixture(
109
+ basename: string
110
+ ): { json: string; base64: string } {
111
+ const json = readTransportFixture(`${basename}.json`);
112
+ const base64 = readTransportFixture(`${basename}.base64.txt`);
113
+ return { json, base64 };
114
+ }
115
+
116
+ function readTransportFixture(
117
+ name: string
118
+ ): string {
119
+ return readFileSync(
120
+ new URL(`../../../Fixtures/Transport/${name}`, import.meta.url),
121
+ "utf8"
122
+ ).trim();
123
+ }
@@ -0,0 +1,471 @@
1
+ export type WebHostTerminalCursorStyle = "block" | "bar" | "underline";
2
+
3
+ export interface WebHostANSIColors {
4
+ black?: string;
5
+ red?: string;
6
+ green?: string;
7
+ yellow?: string;
8
+ blue?: string;
9
+ magenta?: string;
10
+ cyan?: string;
11
+ white?: string;
12
+ brightBlack?: string;
13
+ brightRed?: string;
14
+ brightGreen?: string;
15
+ brightYellow?: string;
16
+ brightBlue?: string;
17
+ brightMagenta?: string;
18
+ brightCyan?: string;
19
+ brightWhite?: string;
20
+ }
21
+
22
+ export interface WebHostTerminalPalette {
23
+ foreground?: string;
24
+ background?: string;
25
+ cursor?: string;
26
+ selectionBackground?: string;
27
+ selectionForeground?: string;
28
+ ansi?: WebHostANSIColors;
29
+ }
30
+
31
+ export interface WebHostTerminalTheme {
32
+ foreground?: string;
33
+ background?: string;
34
+ tint?: string;
35
+ separator?: string;
36
+ selection?: string;
37
+ placeholder?: string;
38
+ link?: string;
39
+ fill?: string;
40
+ windowBackground?: string;
41
+ success?: string;
42
+ warning?: string;
43
+ danger?: string;
44
+ info?: string;
45
+ muted?: string;
46
+ }
47
+
48
+ export interface WebHostTerminalStyle {
49
+ fontSize?: number;
50
+ fontFamily?: string;
51
+ cursorStyle?: WebHostTerminalCursorStyle;
52
+ cursorBlink?: boolean;
53
+ backgroundOpacity?: number;
54
+ palette?: WebHostTerminalPalette;
55
+ theme?: WebHostTerminalTheme;
56
+ }
57
+
58
+ export interface ResolvedWebHostTerminalPalette {
59
+ foreground: string;
60
+ background: string;
61
+ cursor: string;
62
+ selectionBackground: string;
63
+ selectionForeground: string;
64
+ ansi: Required<WebHostANSIColors>;
65
+ }
66
+
67
+ export interface ResolvedWebHostTerminalStyle {
68
+ fontSize: number;
69
+ fontFamily: string;
70
+ cursorStyle: WebHostTerminalCursorStyle;
71
+ cursorBlink: boolean;
72
+ backgroundOpacity: number;
73
+ palette: ResolvedWebHostTerminalPalette;
74
+ theme: Required<WebHostTerminalTheme>;
75
+ }
76
+
77
+ export interface WebHostTerminalRenderStyle {
78
+ appearance: WebHostTerminalAppearance;
79
+ theme?: WebHostTerminalTheme;
80
+ }
81
+
82
+ export interface WebHostTerminalAppearance {
83
+ foregroundColor: string;
84
+ backgroundColor: string;
85
+ tintColor: string;
86
+ palette: Record<string, string>;
87
+ colorSchemeContrast: "standard" | "increased";
88
+ source: "activeQuery" | "environmentHeuristics" | "fallback" | "override";
89
+ }
90
+
91
+ const defaultFontFamily =
92
+ '"SFMono-Regular", "SF Mono", "Menlo", "Monaco", "Consolas", "Liberation Mono", monospace';
93
+
94
+ const defaultANSI: Required<WebHostANSIColors> = {
95
+ black: "#20242c",
96
+ red: "#e05757",
97
+ green: "#61c67b",
98
+ yellow: "#ebb33c",
99
+ blue: "#5ba3ff",
100
+ magenta: "#b46eff",
101
+ cyan: "#56b6c2",
102
+ white: "#eceff4",
103
+ brightBlack: "#8c92ac",
104
+ brightRed: "#ff7b72",
105
+ brightGreen: "#7ee787",
106
+ brightYellow: "#f2cc60",
107
+ brightBlue: "#79c0ff",
108
+ brightMagenta: "#d2a8ff",
109
+ brightCyan: "#7de2d1",
110
+ brightWhite: "#ffffff",
111
+ };
112
+
113
+ const defaultPalette: ResolvedWebHostTerminalPalette = {
114
+ foreground: "#eceff4",
115
+ background: "#1e222a",
116
+ cursor: "#56b6c2",
117
+ selectionBackground: "#2e3440",
118
+ selectionForeground: "#eceff4",
119
+ ansi: defaultANSI,
120
+ };
121
+
122
+ const defaultTheme: Required<WebHostTerminalTheme> = {
123
+ foreground: "#eceff4",
124
+ background: "#1e222a",
125
+ tint: "#56b6c2",
126
+ separator: "#4c566a",
127
+ selection: "#2e3440",
128
+ placeholder: "#8c92ac",
129
+ link: "#5ba3ff",
130
+ fill: "#2b303b",
131
+ windowBackground: "#15181e",
132
+ success: "#61c67b",
133
+ warning: "#ebb33c",
134
+ danger: "#e05757",
135
+ info: "#56b6c2",
136
+ muted: "#8c92ac",
137
+ };
138
+
139
+ export function normalizeWebHostTerminalStyle(
140
+ style: WebHostTerminalStyle = {}
141
+ ): ResolvedWebHostTerminalStyle {
142
+ const palette = normalizePalette(style.palette, defaultPalette);
143
+ const theme = normalizeTheme(style.theme, palette, defaultTheme);
144
+ return {
145
+ fontSize: normalizeFontSize(style.fontSize ?? 14),
146
+ fontFamily: style.fontFamily ?? defaultFontFamily,
147
+ cursorStyle: style.cursorStyle ?? "block",
148
+ cursorBlink: style.cursorBlink ?? false,
149
+ backgroundOpacity: normalizeOpacity(style.backgroundOpacity ?? 1),
150
+ palette,
151
+ theme,
152
+ };
153
+ }
154
+
155
+ export function mergeWebHostTerminalStyle(
156
+ base: WebHostTerminalStyle,
157
+ patch: WebHostTerminalStyle
158
+ ): ResolvedWebHostTerminalStyle {
159
+ const resolvedBase = normalizeWebHostTerminalStyle(base);
160
+ return normalizeWebHostTerminalStyle({
161
+ ...resolvedBase,
162
+ ...patch,
163
+ palette: mergePalette(resolvedBase.palette, patch.palette),
164
+ theme: patch.theme ? { ...resolvedBase.theme, ...patch.theme } : resolvedBase.theme,
165
+ });
166
+ }
167
+
168
+ export function resolveWebHostTerminalRenderStyle(
169
+ style: WebHostTerminalStyle
170
+ ): WebHostTerminalRenderStyle {
171
+ const normalized = normalizeWebHostTerminalStyle(style);
172
+ return {
173
+ appearance: {
174
+ foregroundColor: normalized.theme.foreground,
175
+ backgroundColor: normalized.theme.background,
176
+ tintColor: normalized.theme.tint,
177
+ palette: paletteToIndexedMap(normalized.palette.ansi),
178
+ colorSchemeContrast: contrastRatio(normalized.theme.foreground, normalized.theme.background) >= 7
179
+ ? "increased"
180
+ : "standard",
181
+ source: "override",
182
+ },
183
+ theme: { ...normalized.theme },
184
+ };
185
+ }
186
+
187
+ export function encodeWebHostTerminalRenderStyleBase64(
188
+ style: WebHostTerminalStyle
189
+ ): string {
190
+ return encodeBase64(JSON.stringify(resolveWebHostTerminalRenderStyle(style)));
191
+ }
192
+
193
+ export function decodeWebHostTerminalRenderStyleBase64(
194
+ encoded: string
195
+ ): WebHostTerminalRenderStyle | undefined {
196
+ const json = decodeBase64(encoded);
197
+ if (!json) {
198
+ return undefined;
199
+ }
200
+
201
+ try {
202
+ return JSON.parse(json) as WebHostTerminalRenderStyle;
203
+ } catch {
204
+ return undefined;
205
+ }
206
+ }
207
+
208
+ export function webTUITerminalBackgroundColor(
209
+ style: WebHostTerminalStyle
210
+ ): string {
211
+ const normalized = normalizeWebHostTerminalStyle(style);
212
+ return hexToRgba(normalized.theme.background, normalized.backgroundOpacity);
213
+ }
214
+
215
+ export function applyWebHostTerminalStyle(
216
+ element: HTMLElement,
217
+ style: WebHostTerminalStyle
218
+ ): void {
219
+ const normalized = normalizeWebHostTerminalStyle(style);
220
+ element.style.fontFamily = normalized.fontFamily;
221
+ element.style.fontSize = `${normalized.fontSize}px`;
222
+ element.style.background = hexToRgba(normalized.theme.background, normalized.backgroundOpacity);
223
+ element.style.color = normalized.theme.foreground;
224
+ }
225
+
226
+ function normalizePalette(
227
+ input: WebHostTerminalPalette | undefined,
228
+ defaults: ResolvedWebHostTerminalPalette
229
+ ): ResolvedWebHostTerminalPalette {
230
+ return {
231
+ foreground: normalizeHexColor(input?.foreground ?? defaults.foreground),
232
+ background: normalizeHexColor(input?.background ?? defaults.background),
233
+ cursor: normalizeHexColor(input?.cursor ?? defaults.cursor),
234
+ selectionBackground: normalizeHexColor(
235
+ input?.selectionBackground ?? defaults.selectionBackground
236
+ ),
237
+ selectionForeground: normalizeHexColor(
238
+ input?.selectionForeground ?? defaults.selectionForeground
239
+ ),
240
+ ansi: normalizeANSI(input?.ansi, defaults.ansi),
241
+ };
242
+ }
243
+
244
+ function mergePalette(
245
+ base: ResolvedWebHostTerminalPalette,
246
+ patch: WebHostTerminalPalette | undefined
247
+ ): WebHostTerminalPalette {
248
+ if (!patch) {
249
+ return base;
250
+ }
251
+
252
+ return {
253
+ ...base,
254
+ ...patch,
255
+ ansi: patch.ansi ? { ...base.ansi, ...patch.ansi } : base.ansi,
256
+ };
257
+ }
258
+
259
+ function normalizeANSI(
260
+ input: WebHostANSIColors | undefined,
261
+ defaults: Required<WebHostANSIColors>
262
+ ): Required<WebHostANSIColors> {
263
+ return {
264
+ black: normalizeHexColor(input?.black ?? defaults.black),
265
+ red: normalizeHexColor(input?.red ?? defaults.red),
266
+ green: normalizeHexColor(input?.green ?? defaults.green),
267
+ yellow: normalizeHexColor(input?.yellow ?? defaults.yellow),
268
+ blue: normalizeHexColor(input?.blue ?? defaults.blue),
269
+ magenta: normalizeHexColor(input?.magenta ?? defaults.magenta),
270
+ cyan: normalizeHexColor(input?.cyan ?? defaults.cyan),
271
+ white: normalizeHexColor(input?.white ?? defaults.white),
272
+ brightBlack: normalizeHexColor(input?.brightBlack ?? defaults.brightBlack),
273
+ brightRed: normalizeHexColor(input?.brightRed ?? defaults.brightRed),
274
+ brightGreen: normalizeHexColor(input?.brightGreen ?? defaults.brightGreen),
275
+ brightYellow: normalizeHexColor(input?.brightYellow ?? defaults.brightYellow),
276
+ brightBlue: normalizeHexColor(input?.brightBlue ?? defaults.brightBlue),
277
+ brightMagenta: normalizeHexColor(input?.brightMagenta ?? defaults.brightMagenta),
278
+ brightCyan: normalizeHexColor(input?.brightCyan ?? defaults.brightCyan),
279
+ brightWhite: normalizeHexColor(input?.brightWhite ?? defaults.brightWhite),
280
+ };
281
+ }
282
+
283
+ function normalizeTheme(
284
+ input: WebHostTerminalTheme | undefined,
285
+ palette: ResolvedWebHostTerminalPalette,
286
+ defaults: Required<WebHostTerminalTheme>
287
+ ): Required<WebHostTerminalTheme> {
288
+ const derived = themeFromPalette(palette, defaults);
289
+ return {
290
+ foreground: normalizeHexColor(input?.foreground ?? derived.foreground),
291
+ background: normalizeHexColor(input?.background ?? derived.background),
292
+ tint: normalizeHexColor(input?.tint ?? derived.tint),
293
+ separator: normalizeHexColor(input?.separator ?? derived.separator),
294
+ selection: normalizeHexColor(input?.selection ?? derived.selection),
295
+ placeholder: normalizeHexColor(input?.placeholder ?? derived.placeholder),
296
+ link: normalizeHexColor(input?.link ?? derived.link),
297
+ fill: normalizeHexColor(input?.fill ?? derived.fill),
298
+ windowBackground: normalizeHexColor(input?.windowBackground ?? derived.windowBackground),
299
+ success: normalizeHexColor(input?.success ?? derived.success),
300
+ warning: normalizeHexColor(input?.warning ?? derived.warning),
301
+ danger: normalizeHexColor(input?.danger ?? derived.danger),
302
+ info: normalizeHexColor(input?.info ?? derived.info),
303
+ muted: normalizeHexColor(input?.muted ?? derived.muted),
304
+ };
305
+ }
306
+
307
+ function themeFromPalette(
308
+ palette: ResolvedWebHostTerminalPalette,
309
+ defaults: Required<WebHostTerminalTheme>
310
+ ): Required<WebHostTerminalTheme> {
311
+ return {
312
+ foreground: palette.foreground,
313
+ background: palette.background,
314
+ tint: palette.cursor,
315
+ separator: palette.ansi.brightBlack,
316
+ selection: palette.selectionBackground,
317
+ placeholder: palette.ansi.brightBlack,
318
+ link: palette.ansi.blue,
319
+ fill: defaults.fill,
320
+ windowBackground: palette.background,
321
+ success: palette.ansi.green,
322
+ warning: palette.ansi.yellow,
323
+ danger: palette.ansi.red,
324
+ info: palette.ansi.cyan,
325
+ muted: palette.ansi.brightBlack,
326
+ };
327
+ }
328
+
329
+ function paletteToIndexedMap(
330
+ ansi: Required<WebHostANSIColors>
331
+ ): Record<string, string> {
332
+ return {
333
+ 0: ansi.black,
334
+ 1: ansi.red,
335
+ 2: ansi.green,
336
+ 3: ansi.yellow,
337
+ 4: ansi.blue,
338
+ 5: ansi.magenta,
339
+ 6: ansi.cyan,
340
+ 7: ansi.white,
341
+ 8: ansi.brightBlack,
342
+ 9: ansi.brightRed,
343
+ 10: ansi.brightGreen,
344
+ 11: ansi.brightYellow,
345
+ 12: ansi.brightBlue,
346
+ 13: ansi.brightMagenta,
347
+ 14: ansi.brightCyan,
348
+ 15: ansi.brightWhite,
349
+ };
350
+ }
351
+
352
+ function normalizeFontSize(fontSize: number): number {
353
+ return Number.isFinite(fontSize) && fontSize > 0 ? fontSize : 14;
354
+ }
355
+
356
+ function normalizeOpacity(opacity: number): number {
357
+ if (!Number.isFinite(opacity)) {
358
+ return 1;
359
+ }
360
+
361
+ return Math.min(1, Math.max(0, opacity));
362
+ }
363
+
364
+ function normalizeHexColor(value: string): string {
365
+ const trimmed = value.trim();
366
+ const normalized = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
367
+ if (!/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(normalized)) {
368
+ throw new Error(`Invalid hex color: ${value}`);
369
+ }
370
+
371
+ return normalized.toLowerCase();
372
+ }
373
+
374
+ function hexToRgba(
375
+ color: string,
376
+ opacity: number
377
+ ): string {
378
+ const normalized = normalizeHexColor(color);
379
+ const alpha = normalizeOpacity(opacity);
380
+ const channels = parseHexColor(normalized);
381
+ if (!channels) {
382
+ return normalized;
383
+ }
384
+
385
+ const finalAlpha = Math.round(channels.alpha * alpha * 1000) / 1000;
386
+ return `rgba(${channels.red}, ${channels.green}, ${channels.blue}, ${finalAlpha})`;
387
+ }
388
+
389
+ function parseHexColor(
390
+ color: string
391
+ ): {
392
+ red: number;
393
+ green: number;
394
+ blue: number;
395
+ alpha: number;
396
+ } | undefined {
397
+ const hex = color.startsWith("#") ? color.slice(1) : color;
398
+ const normalized = hex.length === 3 || hex.length === 4
399
+ ? hex.split("").map((ch) => ch + ch).join("")
400
+ : hex;
401
+
402
+ if (normalized.length !== 6 && normalized.length !== 8) {
403
+ return undefined;
404
+ }
405
+
406
+ const red = Number.parseInt(normalized.slice(0, 2), 16);
407
+ const green = Number.parseInt(normalized.slice(2, 4), 16);
408
+ const blue = Number.parseInt(normalized.slice(4, 6), 16);
409
+ const alpha = normalized.length === 8
410
+ ? Number.parseInt(normalized.slice(6, 8), 16) / 255
411
+ : 1;
412
+ return { red, green, blue, alpha };
413
+ }
414
+
415
+ function contrastRatio(
416
+ foreground: string,
417
+ background: string
418
+ ): number {
419
+ const fg = relativeLuminance(foreground);
420
+ const bg = relativeLuminance(background);
421
+ const lighter = Math.max(fg, bg);
422
+ const darker = Math.min(fg, bg);
423
+ return (lighter + 0.05) / (darker + 0.05);
424
+ }
425
+
426
+ function relativeLuminance(color: string): number {
427
+ const channels = parseHexColor(normalizeHexColor(color));
428
+ if (!channels) {
429
+ return 0;
430
+ }
431
+
432
+ const toLinear = (channel: number) => {
433
+ const value = channel / 255;
434
+ return value <= 0.03928
435
+ ? value / 12.92
436
+ : ((value + 0.055) / 1.055) ** 2.4;
437
+ };
438
+
439
+ return 0.2126 * toLinear(channels.red) + 0.7152 * toLinear(channels.green)
440
+ + 0.0722 * toLinear(channels.blue);
441
+ }
442
+
443
+ function encodeBase64(value: string): string {
444
+ if (typeof btoa === "function") {
445
+ const bytes = new TextEncoder().encode(value);
446
+ let binary = "";
447
+ for (const byte of bytes) {
448
+ binary += String.fromCharCode(byte);
449
+ }
450
+ return btoa(binary);
451
+ }
452
+
453
+ return Buffer.from(value, "utf8").toString("base64");
454
+ }
455
+
456
+ function decodeBase64(value: string): string | undefined {
457
+ try {
458
+ if (typeof atob === "function") {
459
+ const binary = atob(value);
460
+ const bytes = new Uint8Array(binary.length);
461
+ for (let index = 0; index < binary.length; index += 1) {
462
+ bytes[index] = binary.charCodeAt(index);
463
+ }
464
+ return new TextDecoder().decode(bytes);
465
+ }
466
+
467
+ return Buffer.from(value, "base64").toString("utf8");
468
+ } catch {
469
+ return undefined;
470
+ }
471
+ }
@@ -0,0 +1,10 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ export function transportFixture(
4
+ basename: string
5
+ ): string {
6
+ return readFileSync(
7
+ new URL(`../../../Fixtures/Transport/${basename}.txt`, import.meta.url),
8
+ "utf8"
9
+ ).replaceAll("\\u001E", "\u001E");
10
+ }