@ccheever/exact-renderer 0.1.0

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 (80) hide show
  1. package/package.json +118 -0
  2. package/src/__tests__/adapter-window-state.test.tsx +190 -0
  3. package/src/__tests__/attrs.test.ts +157 -0
  4. package/src/__tests__/classname.test.ts +332 -0
  5. package/src/__tests__/color.test.ts +169 -0
  6. package/src/__tests__/dom-mirror.test.ts +682 -0
  7. package/src/__tests__/dom-shim.test.ts +274 -0
  8. package/src/__tests__/fixtures/SvelteCounter.svelte +7 -0
  9. package/src/__tests__/fixtures/SvelteInput.svelte +8 -0
  10. package/src/__tests__/host-config.test.ts +51 -0
  11. package/src/__tests__/host-ops.test.ts +2234 -0
  12. package/src/__tests__/image-source.test.ts +135 -0
  13. package/src/__tests__/liquid-glass.test.ts +72 -0
  14. package/src/__tests__/multi-root.test.ts +118 -0
  15. package/src/__tests__/native-view-events.test.ts +102 -0
  16. package/src/__tests__/nodes.test.ts +399 -0
  17. package/src/__tests__/normalize.test.ts +576 -0
  18. package/src/__tests__/paragraph-lowering.test.tsx +144 -0
  19. package/src/__tests__/props.test.ts +518 -0
  20. package/src/__tests__/protocol-encoder.test.ts +732 -0
  21. package/src/__tests__/protocol-fixture-bytes.test.ts +41 -0
  22. package/src/__tests__/reconciler.test.tsx +241 -0
  23. package/src/__tests__/svelte-adapter.test.ts +166 -0
  24. package/src/__tests__/svg-source.test.ts +71 -0
  25. package/src/__tests__/tags.test.ts +354 -0
  26. package/src/__tests__/toggle.test.ts +441 -0
  27. package/src/__tests__/transitions.test.ts +106 -0
  28. package/src/__tests__/web-primitives.test.tsx +454 -0
  29. package/src/__tests__/window-hooks.test.tsx +447 -0
  30. package/src/adapter-contract.ts +68 -0
  31. package/src/attrs.ts +596 -0
  32. package/src/classname-contract.ts +87 -0
  33. package/src/classname-resolve.ts +553 -0
  34. package/src/classname-runtime.ts +29 -0
  35. package/src/components.ts +214 -0
  36. package/src/css-variable-context.ts +83 -0
  37. package/src/dom-hydration.ts +160 -0
  38. package/src/dom-mirror.ts +1459 -0
  39. package/src/dom-shim.ts +1736 -0
  40. package/src/group-context.ts +69 -0
  41. package/src/host-config.ts +431 -0
  42. package/src/host-ops.ts +3167 -0
  43. package/src/image-source.native.ts +703 -0
  44. package/src/image-source.ts +554 -0
  45. package/src/index.ts +278 -0
  46. package/src/inspector-runtime.ts +244 -0
  47. package/src/inspector.ts +3570 -0
  48. package/src/jsx-augmentations.ts +54 -0
  49. package/src/keyboard-avoidance.ts +217 -0
  50. package/src/native-primitives.ts +43 -0
  51. package/src/native-view-events.ts +322 -0
  52. package/src/native-view.ts +60 -0
  53. package/src/nodes/index.ts +41 -0
  54. package/src/nodes/node.ts +531 -0
  55. package/src/peer-context.ts +100 -0
  56. package/src/primitives.native.ts +8 -0
  57. package/src/primitives.ts +8 -0
  58. package/src/props/index.ts +14 -0
  59. package/src/props/normalize.ts +816 -0
  60. package/src/protocol/encoder.ts +940 -0
  61. package/src/protocol/index.ts +33 -0
  62. package/src/reconciler.ts +581 -0
  63. package/src/runtime.ts +11 -0
  64. package/src/safe-area.ts +543 -0
  65. package/src/solid.ts +490 -0
  66. package/src/style/color.js +1 -0
  67. package/src/style/color.ts +15 -0
  68. package/src/style/index.js +1 -0
  69. package/src/style/index.ts +22 -0
  70. package/src/style/normalize.js +1 -0
  71. package/src/style/normalize.ts +1426 -0
  72. package/src/svelte.ts +349 -0
  73. package/src/svg-source.ts +222 -0
  74. package/src/tags/index.ts +21 -0
  75. package/src/tags/tag-map.ts +289 -0
  76. package/src/text/paragraph-lowering.ts +310 -0
  77. package/src/types.ts +1175 -0
  78. package/src/vue.ts +535 -0
  79. package/src/web-host.ts +19 -0
  80. package/src/web-primitives.ts +1654 -0
@@ -0,0 +1,135 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ resolveThemeAwareImageSource,
5
+ resolveImageSourceForDOM,
6
+ resolveImageSourceForNative,
7
+ thumbhashStringToDataUrl,
8
+ } from '../image-source.js';
9
+ import {
10
+ resolveImageSourceForNative as resolveImageSourceForNativeRuntime,
11
+ thumbhashStringToDataUrl as thumbhashStringToDataUrlNativeRuntime,
12
+ } from '../image-source.native.js';
13
+
14
+ describe('image-source DOM ThumbHash', () => {
15
+ it('decodes a known demo ThumbHash to a PNG data URL', () => {
16
+ const url = thumbhashStringToDataUrl('3OcRJYB4d3h/iIeHeEh3eIhw+j3A');
17
+ expect(url).toMatch(/^data:image\/png;base64,/);
18
+ });
19
+
20
+ it('resolves ThumbHash objects for the DOM path', () => {
21
+ const resolved = resolveImageSourceForDOM({
22
+ thumbhash: '3OcRJYB4d3h/iIeHeEh3eIhw+j3A',
23
+ });
24
+ expect(resolved?.src).toMatch(/^data:image\/png;base64,/);
25
+ expect(resolved?.pictureSources).toEqual([]);
26
+ });
27
+
28
+ it('resolves ThumbHash objects for the native path to a data URL', () => {
29
+ const resolved = resolveImageSourceForNative({
30
+ thumbhash: '3OcRJYB4d3h/iIeHeEh3eIhw+j3A',
31
+ });
32
+ expect(resolved).toMatch(/^data:image\/png;base64,/);
33
+ });
34
+
35
+ it('keeps the native startup decoder byte-compatible with the shared helper', () => {
36
+ const thumbhash = '3OcRJYB4d3h/iIeHeEh3eIhw+j3A';
37
+ expect(thumbhashStringToDataUrlNativeRuntime(thumbhash)).toBe(
38
+ thumbhashStringToDataUrl(thumbhash),
39
+ );
40
+ expect(resolveImageSourceForNativeRuntime({ thumbhash })).toBe(
41
+ resolveImageSourceForNative({ thumbhash }),
42
+ );
43
+ });
44
+ });
45
+
46
+ describe('theme-aware image sources', () => {
47
+ it('resolves explicit light and dark sources for native consumers', () => {
48
+ const source = {
49
+ light: '/assets/graph.light.png',
50
+ dark: '/assets/graph.dark.png',
51
+ fallback: '/assets/graph.png',
52
+ };
53
+
54
+ expect(resolveImageSourceForNative(source, 'light')).toBe('/assets/graph.light.png');
55
+ expect(resolveImageSourceForNative(source, 'dark')).toBe('/assets/graph.dark.png');
56
+ });
57
+
58
+ it('falls back when a scheme-specific source is missing', () => {
59
+ const source = {
60
+ dark: '/assets/graph.dark.png',
61
+ fallback: '/assets/graph.png',
62
+ };
63
+
64
+ expect(resolveThemeAwareImageSource(source, 'light')).toBe('/assets/graph.png');
65
+ expect(resolveImageSourceForNative(source, 'dark')).toBe('/assets/graph.dark.png');
66
+ });
67
+
68
+ it('carries theme source metadata for DOM/static controllers', () => {
69
+ const resolved = resolveImageSourceForDOM({
70
+ light: '/assets/graph.light.png',
71
+ dark: '/assets/graph.dark.png',
72
+ }, 'light');
73
+
74
+ expect(resolved?.src).toBe('/assets/graph.light.png');
75
+ expect(resolved?.themeSources).toEqual({
76
+ light: '/assets/graph.light.png',
77
+ dark: '/assets/graph.dark.png',
78
+ });
79
+ });
80
+ });
81
+
82
+ describe('image-source native width-bucket candidates', () => {
83
+ const g = globalThis as {
84
+ exact?: { screenWidth?: number; screenScale?: number };
85
+ devicePixelRatio?: number;
86
+ };
87
+
88
+ afterEach(() => {
89
+ delete g.exact;
90
+ delete g.devicePixelRatio;
91
+ });
92
+
93
+ it('picks the smallest variant that covers the estimated decode width', () => {
94
+ g.exact = { screenWidth: 390 };
95
+ g.devicePixelRatio = 2;
96
+ const uri = resolveImageSourceForNative([
97
+ { uri: 'https://cdn.example/w300', width: 300 },
98
+ { uri: 'https://cdn.example/w600', width: 600 },
99
+ { uri: 'https://cdn.example/w1200', width: 1200 },
100
+ ]);
101
+ expect(uri).toBe('https://cdn.example/w600');
102
+ });
103
+
104
+ it('falls back to the largest variant when the viewport is very wide', () => {
105
+ g.exact = { screenWidth: 900 };
106
+ g.devicePixelRatio = 3;
107
+ const uri = resolveImageSourceForNative([
108
+ { uri: 'https://cdn.example/w300', width: 300 },
109
+ { uri: 'https://cdn.example/w600', width: 600 },
110
+ { uri: 'https://cdn.example/w1200', width: 1200 },
111
+ ]);
112
+ expect(uri).toBe('https://cdn.example/w1200');
113
+ });
114
+
115
+ it('still uses density scoring when pixelDensity is present', () => {
116
+ g.exact = { screenWidth: 390 };
117
+ g.devicePixelRatio = 2;
118
+ const uri = resolveImageSourceForNative([
119
+ { uri: 'https://cdn.example/a', pixelDensity: 1 },
120
+ { uri: 'https://cdn.example/b', pixelDensity: 2 },
121
+ { uri: 'https://cdn.example/c', pixelDensity: 3 },
122
+ ]);
123
+ expect(uri).toBe('https://cdn.example/b');
124
+ });
125
+
126
+ it('falls back to exact.screenScale when devicePixelRatio is not globally exposed', () => {
127
+ g.exact = { screenWidth: 390, screenScale: 2 };
128
+ const uri = resolveImageSourceForNative([
129
+ { uri: 'https://cdn.example/w300', width: 300 },
130
+ { uri: 'https://cdn.example/w600', width: 600 },
131
+ { uri: 'https://cdn.example/w1200', width: 1200 },
132
+ ]);
133
+ expect(uri).toBe('https://cdn.example/w600');
134
+ });
135
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Liquid Glass spec composition.
3
+ *
4
+ * The Contract `glass` / `glassVariant` / `glassTint` / `glassInteractive`
5
+ * attrs lower to host props that normalizeProps composes into a single
6
+ * `glassEffect` value carried on PropId.GlassEffect — a plain boolean for the
7
+ * bare case (so the native toggle's truthy check and the boolean prop contract
8
+ * survive) and a spec string when refined. The native host parses the spec.
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+
13
+ import { normalizeProps } from '../props/normalize.js';
14
+
15
+ describe('Liquid Glass spec composition', () => {
16
+ it('keeps a bare container glass enable a plain boolean', () => {
17
+ const result = normalizeProps('view', { glassEffect: true }, false);
18
+ expect(result.glassEffect).toBe(true);
19
+ });
20
+
21
+ it('encodes the clear variant', () => {
22
+ const result = normalizeProps('view', { glassEffect: true, glassVariant: 'clear' }, false);
23
+ expect(result.glassEffect).toBe('clear');
24
+ });
25
+
26
+ it('encodes variant + tint + interactive into one spec', () => {
27
+ const result = normalizeProps(
28
+ 'view',
29
+ { glassEffect: true, glassVariant: 'regular', glassTint: '#7c4dffaa', glassInteractive: true },
30
+ false,
31
+ );
32
+ expect(result.glassEffect).toBe('regular;tint=#7c4dffaa;interactive');
33
+ });
34
+
35
+ it('treats a string enable (glass="clear") as both enable and variant', () => {
36
+ const result = normalizeProps('view', { glassEffect: 'clear' }, false);
37
+ expect(result.glassEffect).toBe('clear');
38
+ });
39
+
40
+ it('defaults the variant to regular when only a tint is given', () => {
41
+ const result = normalizeProps('view', { glassEffect: true, glassTint: '#11d6c0aa' }, false);
42
+ expect(result.glassEffect).toBe('regular;tint=#11d6c0aa');
43
+ });
44
+
45
+ it('lets an explicit off win even when a variant is present', () => {
46
+ const result = normalizeProps('view', { glassEffect: false, glassVariant: 'clear' }, false);
47
+ // The explicit "false" spec must hit the wire (not be dropped): the
48
+ // protocol has no prop-removal op, so omitting the prop would leave a
49
+ // previously glassy node's stale spec live on native (ENG-22484).
50
+ expect(result.glassEffect).toBe('false');
51
+ });
52
+
53
+ it('emits the explicit off for the glass alias too', () => {
54
+ const result = normalizeProps('view', { glass: false }, false);
55
+ expect(result.glassEffect).toBe('false');
56
+ });
57
+
58
+ it('ignores an empty tint string', () => {
59
+ const result = normalizeProps('view', { glassEffect: true, glassTint: '' }, false);
60
+ expect(result.glassEffect).toBe(true);
61
+ });
62
+
63
+ it('leaves a node with no glass untouched', () => {
64
+ const result = normalizeProps('view', { testId: 'plain' }, false);
65
+ expect(result.glassEffect).toBeUndefined();
66
+ });
67
+
68
+ it('keeps the native toggle glass a boolean (truthy "true" on the wire)', () => {
69
+ const result = normalizeProps('toggle', { glassEffect: true }, false);
70
+ expect(result.glassEffect).toBe(true);
71
+ });
72
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Multi-Root Tests
3
+ *
4
+ * Tests for the multi-root support in nodes, buffer writer, and reconciler.
5
+ */
6
+
7
+ import { describe, it, expect, beforeEach } from 'vitest';
8
+ import {
9
+ NodeKind,
10
+ createRootNode,
11
+ _resetNodeIdCounter,
12
+ } from '../nodes/index.js';
13
+ import { BufferWriter } from '@exact/core/protocol/buffer-writer';
14
+ import {
15
+ PROTOCOL_VERSION_2,
16
+ PROTOCOL_VERSION_3,
17
+ BUFFER_HEADER_V3_SIZE,
18
+ } from '@exact/core/protocol/opcodes';
19
+
20
+ // Reset node ID counter before each test
21
+ beforeEach(() => {
22
+ _resetNodeIdCounter();
23
+ });
24
+
25
+ describe('createRootNode multi-root', () => {
26
+ it('should create root with id=0 and rootId=0 by default', () => {
27
+ const root = createRootNode();
28
+ expect(root.kind).toBe(NodeKind.Root);
29
+ expect(root.id).toBe(0);
30
+ expect(root.rootId).toBe(0);
31
+ expect(root.parent).toBeNull();
32
+ expect(root.children).toEqual([]);
33
+ });
34
+
35
+ it('should create root with id=0 when rootId=0 explicitly', () => {
36
+ const root = createRootNode(0);
37
+ expect(root.id).toBe(0);
38
+ expect(root.rootId).toBe(0);
39
+ });
40
+
41
+ it('should allocate new id when rootId > 0', () => {
42
+ const root = createRootNode(3);
43
+ expect(root.id).toBe(1); // first allocated id
44
+ expect(root.rootId).toBe(3);
45
+ });
46
+
47
+ it('should allocate different ids for different roots', () => {
48
+ const root1 = createRootNode(1);
49
+ const root2 = createRootNode(2);
50
+ expect(root1.id).not.toBe(root2.id);
51
+ expect(root1.rootId).toBe(1);
52
+ expect(root2.rootId).toBe(2);
53
+ });
54
+ });
55
+
56
+ describe('BufferWriter rootId', () => {
57
+ it('should produce 8-byte v2 header with rootId=0 (default)', () => {
58
+ const writer = new BufferWriter(1024, 1, 0);
59
+ writer.createView(1, 0);
60
+ const arr = writer.getUint8Array();
61
+ const view = new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
62
+
63
+ // Protocol version should be v2
64
+ expect(view.getUint16(6, true)).toBe(PROTOCOL_VERSION_2);
65
+
66
+ // Op count should be 1
67
+ expect(view.getUint16(4, true)).toBe(1);
68
+
69
+ // First op should start at offset 8 (v2 header size)
70
+ expect(arr[8]).toBe(0x01); // CreateView opcode
71
+ });
72
+
73
+ it('should produce 12-byte v3 header with rootId=5', () => {
74
+ const writer = new BufferWriter(1024, 1, 5);
75
+ writer.createView(1, 0);
76
+ const arr = writer.getUint8Array();
77
+ const view = new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
78
+
79
+ // Protocol version should be v3
80
+ expect(view.getUint16(6, true)).toBe(PROTOCOL_VERSION_3);
81
+
82
+ // Root ID at offset 8
83
+ expect(view.getUint32(8, true)).toBe(5);
84
+
85
+ // Op count should be 1
86
+ expect(view.getUint16(4, true)).toBe(1);
87
+
88
+ // First op should start at offset 12 (v3 header size)
89
+ expect(arr[12]).toBe(0x01); // CreateView opcode
90
+ });
91
+
92
+ it('should produce correct v3 header size', () => {
93
+ const writer = new BufferWriter(1024, 1, 42);
94
+ const arr = writer.getUint8Array();
95
+
96
+ // With no ops, should just be the 12-byte header
97
+ expect(arr.byteLength).toBe(BUFFER_HEADER_V3_SIZE);
98
+ });
99
+
100
+ it('should reset correctly with v3 header', () => {
101
+ const writer = new BufferWriter(1024, 1, 7);
102
+ writer.createView(1, 0);
103
+ writer.createView(2, 0);
104
+ expect(writer.getOpCount()).toBe(2);
105
+
106
+ writer.reset();
107
+ expect(writer.getOpCount()).toBe(0);
108
+
109
+ writer.createView(3, 0);
110
+ const arr = writer.getUint8Array();
111
+ const view = new DataView(arr.buffer, arr.byteOffset, arr.byteLength);
112
+
113
+ // Should still be v3 after reset
114
+ expect(view.getUint16(6, true)).toBe(PROTOCOL_VERSION_3);
115
+ expect(view.getUint32(8, true)).toBe(7);
116
+ expect(view.getUint16(4, true)).toBe(1);
117
+ });
118
+ });
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import {
4
+ _resetNativeViewEventBridgeForTests,
5
+ registerNativeViewEventHandler,
6
+ unregisterNativeViewEventHandler,
7
+ } from '../native-view-events.js';
8
+
9
+ interface NativeViewEventTestGlobals {
10
+ __exactModuleEvent?: (
11
+ moduleName: string,
12
+ eventName: string,
13
+ nodeIdOrPayload?: unknown,
14
+ payloadMaybe?: unknown,
15
+ ) => void;
16
+ }
17
+
18
+ function encodeMessagePackMap(entries: Record<string, number>): Uint8Array {
19
+ const key = Object.keys(entries)[0]!;
20
+ const value = entries[key]!;
21
+ const keyBytes = new TextEncoder().encode(key);
22
+
23
+ const bytes = new Uint8Array(2 + keyBytes.length + 1);
24
+ bytes[0] = 0x81; // fixmap with one entry
25
+ bytes[1] = 0xa0 | keyBytes.length; // fixstr key
26
+ bytes.set(keyBytes, 2);
27
+ bytes[2 + keyBytes.length] = value; // positive fixint payload
28
+ return bytes;
29
+ }
30
+
31
+ beforeEach(() => {
32
+ _resetNativeViewEventBridgeForTests();
33
+ });
34
+
35
+ afterEach(() => {
36
+ _resetNativeViewEventBridgeForTests();
37
+ });
38
+
39
+ describe('native-view-events', () => {
40
+ it('routes instance-scoped module events by node id and decodes MessagePack payloads', () => {
41
+ const handler = vi.fn().mockReturnValue(true);
42
+ const globals = globalThis as typeof globalThis & NativeViewEventTestGlobals;
43
+
44
+ registerNativeViewEventHandler(42, 'exact.slider', (eventName, payload) => {
45
+ handler(eventName, payload);
46
+ return true;
47
+ });
48
+
49
+ globals.__exactModuleEvent?.(
50
+ 'exact.slider',
51
+ 'change',
52
+ 42,
53
+ encodeMessagePackMap({ value: 55 }),
54
+ );
55
+
56
+ expect(handler).toHaveBeenCalledWith('change', {
57
+ value: 55,
58
+ });
59
+ });
60
+
61
+ it('preserves a pre-existing module dispatcher for non-native-view events', () => {
62
+ const existingDispatcher = vi.fn();
63
+ const globals = globalThis as typeof globalThis & NativeViewEventTestGlobals;
64
+ globals.__exactModuleEvent = existingDispatcher;
65
+
66
+ registerNativeViewEventHandler(7, 'exact.slider', () => false);
67
+
68
+ globals.__exactModuleEvent?.('exact.analytics', 'ready', {
69
+ healthy: true,
70
+ });
71
+
72
+ expect(existingDispatcher).toHaveBeenCalledWith('exact.analytics', 'ready', {
73
+ healthy: true,
74
+ });
75
+ });
76
+
77
+ it('forwards unhandled four-argument events to opt-in dispatchers that understand node ids', () => {
78
+ const globals = globalThis as typeof globalThis & NativeViewEventTestGlobals;
79
+ const calls: unknown[][] = [];
80
+ const existingDispatcher = (
81
+ moduleName: string,
82
+ eventName: string,
83
+ nodeId: unknown,
84
+ payload: unknown,
85
+ ) => {
86
+ calls.push([moduleName, eventName, nodeId, payload]);
87
+ };
88
+ globals.__exactModuleEvent = existingDispatcher;
89
+
90
+ registerNativeViewEventHandler(99, 'exact.slider', () => false);
91
+ unregisterNativeViewEventHandler(99);
92
+
93
+ globals.__exactModuleEvent?.('exact.slider', 'change', 99, new Uint8Array([0xc0]));
94
+
95
+ expect(calls).toEqual([[
96
+ 'exact.slider',
97
+ 'change',
98
+ 99,
99
+ new Uint8Array([0xc0]),
100
+ ]]);
101
+ });
102
+ });