buninu-browser 0.6.0 → 0.9.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 (144) hide show
  1. package/AGENTS.md +5 -0
  2. package/README.md +94 -4
  3. package/buninu-browser.js +1 -1
  4. package/frontends/wayland/README.md +35 -2
  5. package/frontends/wayland/client.js +8 -6
  6. package/frontends/wayland/clipboard.js +1 -1
  7. package/frontends/wayland/index.js +1 -1
  8. package/frontends/wayland/input.js +1 -1
  9. package/frontends/wayland/resource-io.js +6 -0
  10. package/frontends/wayland/transport-posix.js +120 -0
  11. package/frontends/wayland/transport-win32.js +9 -0
  12. package/frontends/wayland/transport.js +3 -120
  13. package/frontends/wayland/win32-resources.js +80 -0
  14. package/frontends/wayland/win32-wire.js +62 -0
  15. package/index.js +10 -6
  16. package/lib/audio/biquad.js +25 -0
  17. package/lib/audio/piano.js +6 -3
  18. package/lib/audio/playback-buffer.js +5 -5
  19. package/lib/audio/pulse-client.js +24 -5
  20. package/lib/audio/web-audio.js +570 -0
  21. package/lib/browser-dialog.js +116 -0
  22. package/lib/cdp-server.js +128 -72
  23. package/lib/file-picker.js +48 -0
  24. package/lib/headless-shell.js +34 -12
  25. package/lib/layout/text-layout.js +112 -19
  26. package/lib/media/mp4-frame-times.js +93 -0
  27. package/lib/paint/direct-canvas.ts +39 -0
  28. package/lib/paint/display-list.js +37 -12
  29. package/lib/paint/raster-readback.js +16 -7
  30. package/lib/renderer/animation-frames.js +10 -5
  31. package/lib/renderer/bindings.js +69 -16
  32. package/lib/renderer/canvas-2d.js +265 -15
  33. package/lib/renderer/canvas-round-rect.js +36 -0
  34. package/lib/renderer/css-animations.js +173 -0
  35. package/lib/renderer/dialog-client.js +30 -0
  36. package/lib/renderer/dialog-worker.js +35 -0
  37. package/lib/renderer/eval-environment.js +28 -0
  38. package/lib/renderer/file-input.js +49 -0
  39. package/lib/renderer/host.js +33 -4
  40. package/lib/renderer/html-drag.js +77 -0
  41. package/lib/renderer/image-loading.js +45 -2
  42. package/lib/renderer/main.js +4 -0
  43. package/lib/renderer/module-transform.js +5 -3
  44. package/lib/renderer/page-builtins.js +59 -0
  45. package/lib/renderer/page-realm.js +177 -40
  46. package/lib/renderer/page-renderer.js +397 -49
  47. package/lib/renderer/piano-input.js +3 -1
  48. package/lib/renderer/raster-surface.js +18 -0
  49. package/lib/renderer/script-rewrite.js +165 -9
  50. package/lib/renderer/video-presentation.js +5 -4
  51. package/lib/renderer/webgl/clip.js +24 -0
  52. package/lib/renderer/webgl/context.js +158 -0
  53. package/lib/renderer/webgl/cpu-fragment.js +211 -0
  54. package/lib/renderer/webgl/draw-cache.js +22 -0
  55. package/lib/renderer/webgl/pipeline.js +70 -0
  56. package/lib/renderer/webgl/shaders.js +58 -0
  57. package/lib/renderer/webgl/textures.js +43 -0
  58. package/lib/renderer/window-named-properties.js +46 -0
  59. package/lib/screenshot-cli.js +4 -7
  60. package/lib/style/animation-values.js +29 -0
  61. package/lib/style/computed-style.js +109 -13
  62. package/lib/style/transform.js +44 -0
  63. package/lib/window-size.js +10 -0
  64. package/package.json +1 -1
  65. package/patches/happy-dom/0007-scope-content-models-to-html-namespace.patch +29 -0
  66. package/patches/happy-dom/README.md +16 -0
  67. package/test/animation-frames.test.js +15 -0
  68. package/test/canvas-2d.test.js +252 -4
  69. package/test/canvas-frame-pacing.test.js +7 -0
  70. package/test/cdp-viewers.test.js +79 -0
  71. package/test/classic-games-patches/0001-fix-game-initialization.patch +177 -0
  72. package/test/classic-games-patches/README.md +156 -0
  73. package/test/classic-games-scenarios.js +133 -0
  74. package/test/clipboard.test.js +16 -0
  75. package/test/computed-style.test.js +18 -0
  76. package/test/css-animation-lifecycle.test.js +28 -0
  77. package/test/css-animations.test.js +83 -0
  78. package/test/direct-canvas.test.js +39 -0
  79. package/test/dynamic-inline-handlers.test.js +25 -0
  80. package/test/eval-declarations.test.js +22 -0
  81. package/test/file-picker.test.js +26 -0
  82. package/test/fixtures/canvas-damage.js +23 -0
  83. package/test/fixtures/canvas-frame-pacing.js +35 -0
  84. package/test/fixtures/direct-canvas.js +96 -0
  85. package/test/fixtures/javascript-dialogs.html +8 -0
  86. package/test/fixtures/media-audio-lifetime.js +65 -0
  87. package/test/fixtures/renderer-memory.js +7 -5
  88. package/test/fixtures/video-black-replay.js +8 -6
  89. package/test/fixtures/video-slow-end.js +39 -0
  90. package/test/fixtures/web-audio-renderer.js +45 -0
  91. package/test/flex.test.js +26 -0
  92. package/test/grid.test.js +13 -0
  93. package/test/headless-shell.test.js +9 -0
  94. package/test/headless-window-size.test.js +28 -0
  95. package/test/html-rcdata.test.js +35 -0
  96. package/test/image-decode.test.js +18 -0
  97. package/test/index.test.js +1 -0
  98. package/test/javascript-dialog.test.js +83 -0
  99. package/test/manual-breakout-paddle.js +42 -0
  100. package/test/manual-breakout-static.js +35 -0
  101. package/test/manual-classic-games.js +41 -0
  102. package/test/manual-clumsy-bird-three-pipes.js +81 -0
  103. package/test/manual-clumsy-bird.js +52 -0
  104. package/test/manual-hextris.js +30 -0
  105. package/test/manual-numbered-music.js +24 -0
  106. package/test/manual-t-rex-runner.js +49 -0
  107. package/test/manual-threejs-scene.js +64 -0
  108. package/test/manual-webgl-benchmark.js +27 -0
  109. package/test/manual-webgl-cpu.html +26 -0
  110. package/test/media-audio-lifetime.test.js +8 -0
  111. package/test/mp4-frame-times.test.js +33 -0
  112. package/test/page-scripts.test.js +96 -1
  113. package/test/piano-input.test.js +15 -2
  114. package/test/piano.test.js +11 -2
  115. package/test/playback-buffer.test.js +11 -0
  116. package/test/pointer-drag.test.js +33 -0
  117. package/test/positioning.test.js +82 -0
  118. package/test/pulse-client.test.js +51 -2
  119. package/test/raster-readback.test.js +14 -0
  120. package/test/script-rewrite.test.js +61 -3
  121. package/test/video-slow-end.test.js +6 -0
  122. package/test/viewport-keyboard-overflow.test.js +25 -0
  123. package/test/wayland-win32-resources.test.js +47 -0
  124. package/test/wayland-win32-wire.test.js +59 -0
  125. package/test/web-audio-renderer.test.js +8 -0
  126. package/test/web-audio.test.js +264 -0
  127. package/test/webgl-clip.test.js +9 -0
  128. package/test/webgl-cpu-fragment.test.js +43 -0
  129. package/test/window-named-properties.test.js +41 -0
  130. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfig.ts +2 -2
  131. package/vendor/happy-dom/packages/happy-dom/src/config/HTMLElementConfigContentModelEnum.ts +1 -0
  132. package/vendor/happy-dom/packages/happy-dom/src/html-parser/HTMLParser.ts +23 -3
  133. package/vendor/happy-dom/packages/happy-dom/src/query-selector/SelectorParser.ts +1 -1
  134. package/vendor/land-renderer/LICENSE +21 -0
  135. package/vendor/land-renderer/README.md +14 -0
  136. package/vendor/land-renderer/direct-compositor.ts +443 -0
  137. package/vendor/land-renderer/frame.js +88 -0
  138. package/vendor/land-renderer/png-fast.js +85 -0
  139. package/vendor/land-renderer/raw-frames.js +66 -0
  140. package/vendor/land-renderer/render-types.ts +7 -0
  141. package/vendor/land-renderer/renderer.ts +134 -0
  142. package/vendor/land-renderer/snapshot.js +37 -0
  143. package/vendor/land-renderer/viewport.js +18 -0
  144. package/vendor/ses/dist/ses.mjs +1 -1
@@ -1,6 +1,6 @@
1
1
  import { beforeAll, expect, test } from "bun:test";
2
2
  import CanvasKitInit from "../usr/lib/canvaskit/canvaskit.js";
3
- import { canvasContext, resetCanvas, setCanvasKit, disposeDocumentCanvases } from "../lib/renderer/canvas-2d.js";
3
+ import { canvasResource, canvasContext, resetCanvas, setCanvasKit, disposeDocumentCanvases } from "../lib/renderer/canvas-2d.js";
4
4
 
5
5
  let CanvasKit;
6
6
 
@@ -38,10 +38,12 @@ test("a colour reads back stably, and one CanvasKit cannot parse is black", () =
38
38
  expect(context.fillStyle).toBe("#000000");
39
39
  expect(sample(context, "not-a-color")).toEqual([0, 0, 0, 255]);
40
40
  expect(sample(context, "red")).toEqual([255, 0, 0, 255]);
41
- expect(sample(context, "rgba(1, 2, 3, 0.5)")).toEqual([1, 2, 3, 128]);
41
+ // Like Chromium, an 8-bit premultiplied backing store rounds RGB at low alpha.
42
+ expect(sample(context, "rgba(1, 2, 3, 0.5)")).toEqual([2, 2, 4, 128]);
42
43
  expect(context.getImageData(0, 0, 1, 1).data.length).toBe(4);
43
44
  expect(context.getImageData(0, 0, 1, 1).data[3]).toBe(0);
44
- expect(canvasContext(canvas(), "webgl")).toBeNull();
45
+ expect(canvasContext(canvas(), "webgl2")).toBeNull();
46
+ expect(canvasContext(canvas(), "webgl").getParameter(0x1F01)).toBe("CanvasKit CPU");
45
47
  });
46
48
 
47
49
  test("the same canvas keeps one context, and changing width clears it", () => {
@@ -93,7 +95,7 @@ test("gradient and pattern facades hide native shaders while style identity and
93
95
  expect(context.strokeStyle).toBe(gradient);
94
96
  const radial = context.createRadialGradient(0, 0, 0, 1, 1, 2);
95
97
  expect(Object.keys(radial)).toEqual(["addColorStop"]);
96
- const pattern = context.createPattern({}, "repeat");
98
+ const pattern = context.createPattern(node, "repeat");
97
99
  expect(Object.keys(pattern)).toEqual(["setTransform"]);
98
100
  for (const name of ["Td", "qf", "pe", "ie", "oe"]) expect(pattern[name]).toBeUndefined();
99
101
  resetCanvas(node);
@@ -134,3 +136,249 @@ test("discarding a document frees its connected and detached canvas pixel buffer
134
136
  CanvasKit._free = free;
135
137
  }
136
138
  });
139
+
140
+
141
+ test("CPU WebGL redraws moved geometry without painting outside its coverage", () => {
142
+ const node=canvas(64,64),gl=canvasContext(node,'webgl'),program=gl.createProgram();
143
+ for(const [type,source] of [[gl.VERTEX_SHADER,'attribute vec2 position; uniform float dx; varying vec3 color; void main(){color=vec3(1.0,0.0,0.0);gl_Position=vec4(position+vec2(dx,0.0),0.0,1.0);}'],
144
+ [gl.FRAGMENT_SHADER,'precision highp float; varying vec3 color; void main(){gl_FragColor=vec4(color,1.0);}']]){
145
+ const shader=gl.createShader(type);gl.shaderSource(shader,source);gl.compileShader(shader);
146
+ expect(gl.getShaderInfoLog(shader)).toBe('');gl.attachShader(program,shader);
147
+ }
148
+ gl.linkProgram(program);gl.useProgram(program);
149
+ gl.bindBuffer(gl.ARRAY_BUFFER,gl.createBuffer());
150
+ gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-.75,-.5,-.25,-.5,-.5,.5]),gl.STATIC_DRAW);
151
+ const a=gl.getAttribLocation(program,'position');gl.enableVertexAttribArray(a);gl.vertexAttribPointer(a,2,gl.FLOAT,false,0,0);
152
+ const dx=gl.getUniformLocation(program,'dx');
153
+ const pixel=(x,y)=>Array.from(canvasResource(node).image.readPixels(x,y,{width:1,height:1,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB}));
154
+ gl.clearColor(0,0,1,1);gl.clear(gl.COLOR_BUFFER_BIT);gl.drawArrays(gl.TRIANGLES,0,3);
155
+ expect(pixel(16,32)).toEqual([255,0,0,255]);expect(pixel(48,32)).toEqual([0,0,255,255]);
156
+ gl.uniform1f(dx,1);gl.clear(gl.COLOR_BUFFER_BIT);gl.drawArrays(gl.TRIANGLES,0,3);
157
+ expect(pixel(16,32)).toEqual([0,0,255,255]);expect(pixel(48,32)).toEqual([255,0,0,255]);
158
+ gl.uniform1f(dx,4);gl.clear(gl.COLOR_BUFFER_BIT);gl.drawArrays(gl.TRIANGLES,0,3);
159
+ expect(pixel(48,32)).toEqual([0,0,255,255]);expect(gl.getError()).toBe(0);
160
+ });
161
+
162
+ test('CPU WebGL samples uploaded RGBA textures and preserves upload snapshots',()=>{
163
+ const node=canvas(16,16),gl=canvasContext(node,'webgl'),p=gl.createProgram();
164
+ for(const [kind,source] of [[gl.VERTEX_SHADER,'attribute vec2 p; varying vec2 uv; void main(){gl_Position=vec4(p,0.,1.);uv=(p+1.)*.5;}'],[gl.FRAGMENT_SHADER,'precision highp float; varying vec2 uv; uniform sampler2D image; void main(){gl_FragColor=texture2D(image,uv);}']]){
165
+ const s=gl.createShader(kind);gl.shaderSource(s,source);gl.compileShader(s);expect(gl.getShaderInfoLog(s)).toBe('');gl.attachShader(p,s);
166
+ }
167
+ gl.linkProgram(p);gl.useProgram(p);gl.bindBuffer(gl.ARRAY_BUFFER,gl.createBuffer());gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);gl.enableVertexAttribArray(0);gl.vertexAttribPointer(0,2,gl.FLOAT,false,0,0);
168
+ gl.bindTexture(gl.TEXTURE_2D,gl.createTexture());gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MAG_FILTER,gl.NEAREST);
169
+ gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,1,2,0,gl.RGBA,gl.UNSIGNED_BYTE,new Uint8Array([255,0,0,255,0,0,255,255]));gl.drawArrays(gl.TRIANGLES,0,6);
170
+ const read=y=>Array.from(canvasResource(node).image.readPixels(8,y,{width:1,height:1,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB}));
171
+ expect(read(2)).toEqual([0,0,255,255]);expect(read(13)).toEqual([255,0,0,255]);expect(gl.getError()).toBe(0);
172
+ });
173
+
174
+ test('cached background shading follows moving depth occluders and LEQUAL preserves coplanar copies',()=>{
175
+ const node=canvas(32,32),gl=canvasContext(node,'webgl'),p=gl.createProgram();
176
+ for(const [kind,source]of[[gl.VERTEX_SHADER,'attribute vec2 p;uniform float dx;uniform float z;void main(){gl_Position=vec4(p+vec2(dx,0.),z,1.);}'],[gl.FRAGMENT_SHADER,'precision highp float;uniform vec4 color;void main(){gl_FragColor=color;}']]){
177
+ const sh=gl.createShader(kind);gl.shaderSource(sh,source);gl.compileShader(sh);expect(gl.getShaderInfoLog(sh)).toBe('');gl.attachShader(p,sh);
178
+ }
179
+ gl.linkProgram(p);gl.useProgram(p);const buffer=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,buffer);gl.enableVertexAttribArray(0);gl.vertexAttribPointer(0,2,gl.FLOAT,false,0,0);
180
+ gl.enable(gl.DEPTH_TEST);gl.depthFunc(gl.LEQUAL);
181
+ const dx=gl.getUniformLocation(p,'dx'),z=gl.getUniformLocation(p,'z'),color=gl.getUniformLocation(p,'color');
182
+ const quad=(left,right,offset,depth,rgba)=>{gl.uniform1f(dx,offset);gl.uniform1f(z,depth);gl.uniform4fv(color,rgba);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([left,-1,right,-1,left,1,left,1,right,-1,right,1]),gl.STATIC_DRAW);gl.drawArrays(gl.TRIANGLES,0,6);};
183
+ const pixel=x=>Array.from(canvasResource(node).image.readPixels(x,16,{width:1,height:1,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB}));
184
+ for(const offset of[0,1,0]){
185
+ gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
186
+ quad(-1,0,offset,-.123456789,[1,0,0,1]);quad(-1,1,0,.3,[0,0,1,1]);
187
+ expect(pixel(offset?24:8)).toEqual([255,0,0,255]);expect(pixel(offset?8:24)).toEqual([0,0,255,255]);
188
+ }
189
+ gl.clear(gl.COLOR_BUFFER_BIT|gl.DEPTH_BUFFER_BIT);
190
+ quad(-1,1,0,.123456789,[1,0,0,1]);quad(-1,1,0,.123456789,[0,1,0,1]);
191
+ const image=canvasResource(node).image.readPixels(0,0,{width:32,height:32,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB});
192
+ for(let i=0;i<image.length;i+=4)expect(Array.from(image.slice(i,i+4))).toEqual([0,255,0,255]);expect(gl.getError()).toBe(0);
193
+ });
194
+
195
+ test('texture alpha is unpremultiplied in both numeric and CanvasKit fragment paths',()=>{
196
+ for(const native of[false,true]){
197
+ const node=canvas(8,8),gl=canvasContext(node,'webgl'),p=gl.createProgram();
198
+ for(const [kind,source]of[[gl.VERTEX_SHADER,'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}'],[gl.FRAGMENT_SHADER,`precision highp float;uniform sampler2D image;void main(){vec4 c=texture2D(image,vec2(.5));${native?'for(int i=0;i<1;i++){c.r+=0.;}':''}gl_FragColor=c;}`]]){
199
+ const sh=gl.createShader(kind);gl.shaderSource(sh,source);gl.compileShader(sh);expect(gl.getShaderInfoLog(sh)).toBe('');gl.attachShader(p,sh);
200
+ }
201
+ gl.linkProgram(p);gl.useProgram(p);gl.bindBuffer(gl.ARRAY_BUFFER,gl.createBuffer());gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]),gl.STATIC_DRAW);gl.enableVertexAttribArray(0);gl.vertexAttribPointer(0,2,gl.FLOAT,false,0,0);
202
+ gl.bindTexture(gl.TEXTURE_2D,gl.createTexture());gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,1,1,0,gl.RGBA,gl.UNSIGNED_BYTE,new Uint8Array([200,100,50,128]));
203
+ gl.clearColor(0,0,1,1);gl.clear(gl.COLOR_BUFFER_BIT);gl.drawArrays(gl.TRIANGLES,0,6);
204
+ const pixel=canvasResource(node).image.readPixels(2,2,{width:1,height:1,colorType:CanvasKit.ColorType.RGBA_8888,alphaType:CanvasKit.AlphaType.Unpremul,colorSpace:CanvasKit.ColorSpace.SRGB});
205
+ for(let i=0;i<4;i++)expect(Math.abs(pixel[i]-[100,50,152,255][i])).toBeLessThanOrEqual(1);
206
+ }
207
+ });
208
+
209
+ test('patterns snapshot actual image sources and survive source drawing and bitmap resets',()=>{
210
+ const document={},source={...canvas(2,2),ownerDocument:document},target={...canvas(4,4),ownerDocument:document};
211
+ const src=canvasContext(source,'2d'),dst=canvasContext(target,'2d');
212
+ try{
213
+ src.fillStyle='red';src.fillRect(0,0,2,2);
214
+ const pattern=dst.createPattern(source,'repeat');
215
+ src.fillStyle='blue';src.fillRect(0,0,2,2);resetCanvas(source);
216
+ dst.fillStyle=pattern;dst.fillRect(0,0,4,4);
217
+ expect([...dst.getImageData(3,3,1,1).data]).toEqual([255,0,0,255]);
218
+ resetCanvas(target);dst.fillStyle=pattern;dst.fillRect(0,0,4,4);
219
+ expect([...dst.getImageData(3,3,1,1).data]).toEqual([255,0,0,255]);
220
+ expect(()=>dst.createPattern({},'repeat')).toThrow(TypeError);
221
+ expect(()=>dst.createPattern(source,'invalid')).toThrow();
222
+ }finally{disposeDocumentCanvases(document);}
223
+ });
224
+
225
+ test('pattern sampling follows the drawing context and survives save/restore and reset',()=>{
226
+ const source=canvas(2,1),src=canvasContext(source,'2d');
227
+ src.fillStyle='red';src.fillRect(0,0,1,1);
228
+ src.fillStyle='blue';src.fillRect(1,0,1,1);
229
+ const target=canvas(8,2),ctx=canvasContext(target,'2d');
230
+ const pattern=src.createPattern(source,'repeat');
231
+ pattern.setTransform({a:4,b:0,c:0,d:1,e:0,f:0});
232
+ ctx.fillStyle=pattern;
233
+ expect(ctx.imageSmoothingEnabled).toBe(true);
234
+ expect(ctx.imageSmoothingQuality).toBe('low');
235
+ ctx.imageSmoothingEnabled=false;
236
+ ctx.save();ctx.imageSmoothingEnabled=true;ctx.imageSmoothingQuality='high';ctx.restore();
237
+ expect(ctx.imageSmoothingEnabled).toBe(false);
238
+ expect(ctx.imageSmoothingQuality).toBe('low');
239
+ ctx.fillRect(0,0,8,1);
240
+ expect([...ctx.getImageData(3,0,1,1).data]).toEqual([255,0,0,255]);
241
+ ctx.imageSmoothingEnabled=true;ctx.fillRect(0,1,8,1);
242
+ const blended=ctx.getImageData(3,1,1,1).data;
243
+ expect(blended[0]).toBeGreaterThan(0);expect(blended[2]).toBeGreaterThan(0);
244
+ resetCanvas(target);
245
+ expect(ctx.imageSmoothingEnabled).toBe(true);
246
+ expect(ctx.imageSmoothingQuality).toBe('low');
247
+ });
248
+
249
+ test('drawImage honors smoothing and zero destination width',()=>{
250
+ const source=canvas(2,1),src=canvasContext(source,'2d');
251
+ src.fillStyle='red';src.fillRect(0,0,1,1);src.fillStyle='blue';src.fillRect(1,0,1,1);
252
+ const target=canvas(8,3),ctx=canvasContext(target,'2d',{resolveImage:canvasResource});
253
+ ctx.imageSmoothingEnabled=false;ctx.drawImage(source,0,0,8,1);
254
+ expect([...ctx.getImageData(3,0,1,1).data]).toEqual([255,0,0,255]);
255
+ ctx.imageSmoothingEnabled=true;ctx.drawImage(source,0,1,8,1);
256
+ const blended=ctx.getImageData(3,1,1,1).data;
257
+ expect(blended[0]).toBeGreaterThan(0);expect(blended[2]).toBeGreaterThan(0);
258
+ ctx.drawImage(source,0,2,0,1);
259
+ expect([...ctx.getImageData(0,2,1,1).data]).toEqual([0,0,0,0]);
260
+ });
261
+
262
+ test('pixel-aligned pattern blits match shader fills including clips, alpha and repetition limits',()=>{
263
+ const source=canvas(3,3),src=canvasContext(source,'2d');
264
+ src.fillStyle='rgba(200,40,20,.5)';src.fillRect(0,0,3,3);
265
+ src.fillStyle='blue';src.fillRect(1,1,1,1);
266
+ for(const repetition of ['repeat','repeat-x','repeat-y','no-repeat']){
267
+ const outputs=[];
268
+ for(const smooth of [false,true]){
269
+ const target=canvas(8,6),ctx=canvasContext(target,'2d');
270
+ ctx.fillStyle='green';ctx.fillRect(0,0,8,6);
271
+ ctx.imageSmoothingEnabled=smooth;ctx.globalAlpha=.6;
272
+ const pattern=ctx.createPattern(source,repetition);
273
+ pattern.setTransform({a:1,b:0,c:0,d:1,e:-1,f:1});ctx.fillStyle=pattern;
274
+ ctx.save();ctx.beginPath();ctx.rect(1,1,6,4);ctx.clip();ctx.translate(1,-1);
275
+ ctx.fillRect(8,7,-12,-10);ctx.restore();
276
+ outputs.push([...ctx.getImageData(0,0,8,6).data]);
277
+ }
278
+ expect(outputs[0]).toEqual(outputs[1]);
279
+ }
280
+ });
281
+
282
+ test('a failed Canvas context constructor releases its raster and restores the factory',()=>{
283
+ const makeCanvas=CanvasKit.MakeCanvas,makeSurface=CanvasKit.MakeSurface,free=CanvasKit.Free;
284
+ let released=0;
285
+ CanvasKit.Free=allocation=>{released++;return free(allocation);};
286
+ CanvasKit.MakeCanvas=(width,height)=>{CanvasKit.MakeSurface(width,height);throw Error('context construction failed');};
287
+ try{
288
+ expect(()=>canvasContext(canvas(4,4),'2d')).toThrow('context construction failed');
289
+ expect(released).toBe(1);
290
+ expect(CanvasKit.MakeSurface).toBe(makeSurface);
291
+ }finally{CanvasKit.MakeCanvas=makeCanvas;CanvasKit.Free=free;}
292
+ });
293
+
294
+ test('drawImage ignores fill color and shaders while preserving globalAlpha and fill state',()=>{
295
+ const document={},source={ownerDocument:document,width:2,height:1},target={ownerDocument:document,width:2,height:1};
296
+ const src=canvasContext(source,'2d'),ctx=canvasContext(target,'2d',{resolveImage:canvasResource});
297
+ try{
298
+ src.fillStyle='red';src.fillRect(0,0,2,1);
299
+ const gradient=ctx.createLinearGradient(0,0,2,0);gradient.addColorStop(0,'transparent');gradient.addColorStop(1,'blue');
300
+ const pattern=ctx.createPattern(source,'repeat');
301
+ for(const style of ['rgba(0,0,255,0)', 'rgba(0,0,255,.25)',gradient,pattern]){
302
+ ctx.clearRect(0,0,2,1);ctx.fillStyle=style;const before=ctx.fillStyle;ctx.globalAlpha=1;ctx.drawImage(source,0,0);
303
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,255]);expect(ctx.fillStyle).toBe(before);
304
+ ctx.clearRect(0,0,2,1);ctx.globalAlpha=.5;ctx.drawImage(source,0,0,2,1);
305
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,128]);
306
+ ctx.save();ctx.fillStyle='green';ctx.restore();ctx.clearRect(0,0,2,1);ctx.drawImage(source,0,0);
307
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,128]);
308
+ }
309
+ }finally{disposeDocumentCanvases(document)}
310
+ });
311
+
312
+ test('image paints keep blend state across restore without constructing fill-pattern shaders',()=>{
313
+ const document={},source={ownerDocument:document,width:2,height:1},target={ownerDocument:document,width:2,height:1};
314
+ const src=canvasContext(source,'2d'),ctx=canvasContext(target,'2d',{resolveImage:canvasResource});
315
+ const make=CanvasKit.Image.prototype.makeShaderOptions;let shaders=0;
316
+ try{
317
+ src.fillStyle='red';src.fillRect(0,0,2,1);ctx.fillStyle=ctx.createPattern(source,'repeat');
318
+ CanvasKit.Image.prototype.makeShaderOptions=function(...args){shaders++;return make.apply(this,args)};
319
+ for(let i=0;i<3;i++){ctx.save();ctx.globalAlpha=.5;ctx.globalCompositeOperation='copy';ctx.drawImage(source,0,0);ctx.restore();}
320
+ expect(shaders).toBe(0);
321
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,128]);
322
+ ctx.globalAlpha=1;ctx.globalCompositeOperation='source-over';ctx.drawImage(source,0,0);
323
+ expect([...ctx.getImageData(0,0,1,1).data]).toEqual([255,0,0,255]);
324
+ }finally{CanvasKit.Image.prototype.makeShaderOptions=make;disposeDocumentCanvases(document)}
325
+ });
326
+
327
+ test('roundRect draws rounded corners, mirrors negative dimensions and rejects invalid radii',()=>{
328
+ const context=canvasContext(canvas(40,40),'2d');
329
+ expect('roundRect' in context).toBe(true);
330
+ context.fillStyle='red';context.beginPath();context.roundRect(2,2,30,30,8);context.fill();
331
+ expect(context.getImageData(2,2,1,1).data[3]).toBe(0);
332
+ expect([...context.getImageData(17,17,1,1).data]).toEqual([255,0,0,255]);
333
+ expect(context.getImageData(17,3,1,1).data[3]).toBeGreaterThan(200);
334
+ context.clearRect(0,0,40,40);context.beginPath();context.roundRect(32,2,-30,30,[{x:8,y:12},0,0,0]);context.fill();
335
+ expect(context.getImageData(31,2,1,1).data[3]).toBe(0);
336
+ expect(context.getImageData(2,2,1,1).data[3]).toBeGreaterThan(200);
337
+ expect(()=>context.roundRect(0,0,20,20,-1)).toThrow(RangeError);
338
+ expect(()=>context.roundRect(0,0,20,20,[])).toThrow(RangeError);
339
+ expect(()=>context.roundRect(0,0,20,20,[1,2,3,4,5])).toThrow(RangeError);
340
+ context.clearRect(0,0,40,40);context.beginPath();context.roundRect(2,2,10,10,50);context.fill();
341
+ expect(context.getImageData(7,7,1,1).data[3]).toBeGreaterThan(200);
342
+ });
343
+
344
+ test('cached radial rectangles preserve transforms, clip, alpha and mutable stops',()=>{
345
+ for(const scale of [1,1.5]){
346
+ const a=canvas(40,40),b=canvas(40,40),ca=canvasContext(a,'2d'),cb=canvasContext(b,'2d');
347
+ const gradients=[];
348
+ for(const c of [ca,cb]){
349
+ c.translate(.25,.5);c.scale(scale,scale);
350
+ c.beginPath();c.rect(1,2,20,18);c.clip();
351
+ const g=c.createRadialGradient(9,10,0,9,10,20);
352
+ g.addColorStop(0,'red');g.addColorStop(1,'transparent');c.fillStyle=g;gradients.push(g);
353
+ }
354
+ for(let i=0;i<3;i++){
355
+ if(i===2)for(const g of gradients)g.addColorStop(.5,'blue');
356
+ for(const c of [ca,cb]){c.clearRect(-5,-5,60,60);c.globalAlpha=.4;}
357
+ ca.fillRect(.5,1.25,22,20);
358
+ cb.beginPath();cb.rect(.5,1.25,22,20);cb.fill();
359
+ const x=ca.getImageData(0,0,40,40).data,y=cb.getImageData(0,0,40,40).data;
360
+ for(let j=0;j<x.length;j+=4){
361
+ expect(Math.abs(x[j+3]-y[j+3])).toBeLessThanOrEqual(1);
362
+ for(let k=0;k<3;k++)expect(Math.abs(x[j+k]*x[j+3]/255-y[j+k]*y[j+3]/255)).toBeLessThanOrEqual(2);
363
+ }
364
+ }
365
+ for(const node of [a,b]){node.width=1;resetCanvas(node);}
366
+ }
367
+ });
368
+
369
+ test('vertical gradient strips match full shading under scaling and clipping',()=>{
370
+ for(const scale of [1,1.5]){
371
+ const nodes=[canvas(36,36),canvas(36,36)],contexts=nodes.map(n=>canvasContext(n,'2d'));
372
+ for(const c of contexts){
373
+ c.scale(scale,scale);c.beginPath();c.rect(2,3,18,17);c.clip();
374
+ const g=c.createLinearGradient(0,0,0,24);g.addColorStop(0,'red');g.addColorStop(.4,'rgba(0,255,0,.4)');g.addColorStop(1,'blue');c.fillStyle=g;c.globalAlpha=.6;
375
+ }
376
+ contexts[0].fillRect(0,0,24,24);contexts[1].beginPath();contexts[1].rect(0,0,24,24);contexts[1].fill();
377
+ const [x,y]=contexts.map(c=>c.getImageData(0,0,36,36).data);
378
+ for(let j=0;j<x.length;j+=4){
379
+ expect(Math.abs(x[j+3]-y[j+3])).toBeLessThanOrEqual(1);
380
+ for(let k=0;k<3;k++)expect(Math.abs(x[j+k]*x[j+3]/255-y[j+k]*y[j+3]/255)).toBeLessThanOrEqual(2);
381
+ }
382
+ for(const n of nodes){n.width=1;resetCanvas(n);}
383
+ }
384
+ });
@@ -0,0 +1,7 @@
1
+ import {test,expect} from 'bun:test';
2
+ test('a slow earlier layout does not throttle later Canvas-only frames',async()=>{
3
+ const p=Bun.spawn([process.execPath,new URL('./fixtures/canvas-frame-pacing.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
4
+ const [exit,stderr,stdout]=await Promise.all([p.exited,new Response(p.stderr).text(),new Response(p.stdout).text()]);
5
+ expect({exit,stderr}).toEqual({exit:0,stderr:''});
6
+ expect(JSON.parse(stdout).layoutsDuringAnimation).toBe(0);
7
+ },15000);
@@ -0,0 +1,79 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {CdpServer} from '../lib/cdp-server.js';
3
+ const until=async fn=>{for(let i=0;i<200;i++){if(fn())return;await Bun.sleep(5);}throw Error('viewer update timed out');};
4
+ async function connect(url){
5
+ const ws=new WebSocket(url), events=[], pending=new Map();let id=0;
6
+ ws.onmessage=({data})=>{const m=JSON.parse(data);if(m.id){const p=pending.get(m.id);pending.delete(m.id);m.error?p.reject(Error(m.error.message)):p.resolve(m.result);}else events.push(m);};
7
+ await new Promise((resolve,reject)=>{ws.onopen=resolve;ws.onerror=reject;});
8
+ return {ws,events,send(method,params={},sessionId){return new Promise((resolve,reject)=>{pending.set(++id,{resolve,reject});ws.send(JSON.stringify({id,method,params,sessionId}));});}};
9
+ }
10
+ for(const direct of [false,true])test(`independent viewer ACK, stop and disconnect (${direct?'page sockets':'sessions'})`,async()=>{
11
+ let frame;const server=CdpServer.create({screenshot:options=>options.format??'png',onFrame:fn=>frame=fn}).listen(0);
12
+ const clients=[];
13
+ try{
14
+ const root=await connect(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);clients.push(root);
15
+ const {targetId}=await root.send('Target.createTarget',{url:'about:blank'});
16
+ async function viewer(){const c=await connect(`ws://127.0.0.1:${server.port}/devtools/${direct?'page/'+targetId:'browser/cdp-server'}`);clients.push(c);const sid=direct?undefined:(await c.send('Target.attachToTarget',{targetId,flatten:true})).sessionId;return {c,sid,send:(m,p)=>c.send(m,p,sid),frames:()=>c.events.filter(e=>e.method==='Page.screencastFrame')};}
17
+ const a=await viewer(),b=await viewer();
18
+ await a.send('Page.startScreencast',{format:'png'});await b.send('Page.startScreencast',{format:'jpeg'});
19
+ await until(()=>a.frames().length===1&&b.frames().length===1);
20
+ expect(a.frames()[0].params.data).toBe('png');expect(b.frames()[0].params.data).toBe('jpeg');
21
+ frame();await until(()=>a.frames().length===2&&b.frames().length===2);
22
+ for(let i=0;i<5;i++)frame();
23
+ // A cannot ACK B's frames, even when both watch the same target.
24
+ await a.send('Page.screencastFrameAck',{sessionId:b.frames()[0].params.sessionId});
25
+ await Bun.sleep(20);expect(a.frames()).toHaveLength(2);expect(b.frames()).toHaveLength(2);
26
+ await b.send('Page.screencastFrameAck',{sessionId:b.frames()[0].params.sessionId});await until(()=>b.frames().length===3);
27
+ expect(a.frames()).toHaveLength(2);
28
+ await a.send('Page.stopScreencast');
29
+ await b.send('Page.screencastFrameAck',{sessionId:b.frames()[1].params.sessionId});frame();await until(()=>b.frames().length===4);
30
+ a.c.ws.close();await Bun.sleep(20);
31
+ await b.send('Page.screencastFrameAck',{sessionId:b.frames()[2].params.sessionId});frame();await until(()=>b.frames().length===5);
32
+ const replacement=await viewer();await replacement.send('Page.startScreencast');await until(()=>replacement.frames().length===1);
33
+ await b.send('Page.stopScreencast');frame();await until(()=>replacement.frames().length===2);
34
+ if(!direct){
35
+ await expect(root.send('Page.stopScreencast',{},b.sid)).rejects.toThrow('No session owned');
36
+ const {sessionId}=await replacement.c.send('Target.attachToTarget',{targetId,flatten:true});
37
+ await replacement.c.send('Page.startScreencast',{format:'jpeg'},sessionId);
38
+ await until(()=>replacement.c.events.some(e=>e.method==='Page.screencastFrame'&&e.sessionId===sessionId));
39
+ await replacement.send('Page.stopScreencast');frame();
40
+ await until(()=>replacement.c.events.filter(e=>e.method==='Page.screencastFrame'&&e.sessionId===sessionId).length===2);
41
+ }
42
+ }finally{for(const c of clients)c.ws.close();server.stop(true);}
43
+ });
44
+
45
+ test('a key-up received during slow key-down IPC is not overwritten by its completion',async()=>{
46
+ const calls=[];let release;
47
+ const server=CdpServer.create({press:(key,options)=>{
48
+ calls.push([key,options.eventType]);
49
+ if(calls.length===1)return new Promise(resolve=>{release=resolve});
50
+ }}).listen(0);
51
+ let client;
52
+ try{
53
+ client=await connect(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
54
+ const {targetId}=await client.send('Target.createTarget',{url:'about:blank'});
55
+ const {sessionId}=await client.send('Target.attachToTarget',{targetId,flatten:true});
56
+ const send=type=>client.send('Input.dispatchKeyEvent',{type,key:'w',code:'KeyW'},sessionId);
57
+ const down=send('keyDown');await until(()=>calls.length===1);
58
+ await send('keyUp');release();await down;
59
+ await send('keyDown');await send('keyUp');
60
+ expect(calls).toEqual([['w','keydown'],['w','keyup'],['w','keydown'],['w','keyup']]);
61
+ }finally{release?.();client?.ws.close();server.stop(true);}
62
+ });
63
+
64
+ test('CDP keeps independent held keys and matches releases even when modifiers change',async()=>{
65
+ const calls=[];
66
+ const server=CdpServer.create({press:async(key,options)=>calls.push([key,options.eventType])}).listen(0);
67
+ let client;
68
+ try{
69
+ client=await connect(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
70
+ const {targetId}=await client.send('Target.createTarget',{url:'about:blank'});
71
+ const {sessionId}=await client.send('Target.attachToTarget',{targetId,flatten:true});
72
+ const send=(type,key,modifiers=0)=>client.send('Input.dispatchKeyEvent',{type,key,code:'Key'+key.toUpperCase(),modifiers},sessionId);
73
+ await send('keyDown','a');await send('keyDown','b',1);await send('keyUp','a');
74
+ await send('char','b',1); // A's release must not make B's char a second press.
75
+ await client.send('Input.dispatchKeyEvent',{type:'char',text:'b'},sessionId); // code may be omitted
76
+ await send('keyUp','b',0);await send('keyDown','b',1);await send('keyUp','b');
77
+ expect(calls).toEqual([['a','keydown'],['b','keydown'],['a','keyup'],['b','keyup'],['b','keydown'],['b','keyup']]);
78
+ }finally{client?.ws.close();server.stop(true);}
79
+ });
@@ -0,0 +1,177 @@
1
+ diff --git a/games/breakout/index.html b/games/breakout/index.html
2
+ index 3fc45d2..1b11750 100644
3
+ --- a/games/breakout/index.html
4
+ +++ b/games/breakout/index.html
5
+ @@ -240,6 +240,10 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
6
+ let score=0,lives=MAX_LIVES;
7
+ let paddleX,ballX,ballY,ballVX,ballVY;
8
+ let bricks=[];
9
+ + const staticLayer=document.createElement('canvas');
10
+ + staticLayer.width=W;staticLayer.height=H;
11
+ + const staticContext=staticLayer.getContext('2d');
12
+ + let staticLayerDirty=true;
13
+ let particles=[];
14
+ let ballSpeed=4.5;
15
+ let ballLaunched=false;
16
+ @@ -250,6 +254,7 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
17
+ let highScore=0;
18
+
19
+ function initBricks(){
20
+ + staticLayerDirty=true;
21
+ bricks=[];
22
+ for(let r=0;r<BRICK_ROWS;r++){
23
+ for(let c=0;c<BRICK_COLS;c++){
24
+ @@ -331,10 +336,11 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
25
+ }
26
+
27
+ // ---- UPDATE ----
28
+ - function update(){
29
+ + function update(elapsed){
30
+ if(state==='playing'&&mouseX!==null){
31
+ const target=mouseX-PADDLE_W/2;
32
+ - const speed=8;
33
+ + // Preserve 8px/frame at 60 Hz without slowing mouse tracking at low FPS.
34
+ + const speed=480*elapsed;
35
+ if(Math.abs(paddleX-target)>2){
36
+ if(paddleX<target) paddleX+=Math.min(speed,target-paddleX);
37
+ else paddleX-=Math.min(speed,paddleX-target);
38
+ @@ -386,6 +392,7 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
39
+ const bx=b.x,by=b.y,bw=b.w,bh=b.h;
40
+ if(ballX+BALL_SIZE>bx&&ballX<bx+bw&&ballY+BALL_SIZE>by&&ballY<by+bh){
41
+ b.alive=false;
42
+ + staticLayerDirty=true;
43
+ const cx=bx+bw/2,cy=by+bh/2;
44
+ emitParticles(cx,cy,12,b.color.fill);
45
+ sfxBrick(330+b.row*60);
46
+ @@ -462,7 +469,7 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
47
+ }
48
+
49
+ // ---- DRAW ----
50
+ - function draw(){
51
+ + function redrawStaticLayer(ctx){
52
+ ctx.clearRect(0,0,W,H);
53
+
54
+ // Grid dots
55
+ @@ -490,6 +497,13 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
56
+ ctx.restore();
57
+ }
58
+
59
+ + }
60
+ +
61
+ + function draw(){
62
+ + if(staticLayerDirty){redrawStaticLayer(staticContext);staticLayerDirty=false;}
63
+ + ctx.clearRect(0,0,W,H);
64
+ + ctx.drawImage(staticLayer,0,0);
65
+ +
66
+ // Paddle
67
+ ctx.save();
68
+ ctx.shadowColor='rgba(255,107,107,0.5)';
69
+ @@ -605,8 +619,12 @@ canvas{display:block;width:100%;height:100%;object-fit:contain;touch-action:mani
70
+ }
71
+
72
+ // ---- LOOP ----
73
+ - function loop(){
74
+ - update();
75
+ + let lastFrameTime=null;
76
+ + function loop(time=performance.now()){
77
+ + // Bound catch-up after a background tab or a long suspended frame.
78
+ + const elapsed=lastFrameTime===null?1/60:Math.min(0.1,Math.max(0,(time-lastFrameTime)/1000));
79
+ + lastFrameTime=time;
80
+ + update(elapsed);
81
+ updateParticles();
82
+ draw();
83
+ animId=requestAnimationFrame(loop);
84
+ diff --git a/games/mine/index.html b/games/mine/index.html
85
+ index 01e972d..91b0e32 100644
86
+ --- a/games/mine/index.html
87
+ +++ b/games/mine/index.html
88
+ @@ -415,6 +415,8 @@ board.addEventListener('click',e=>{
89
+
90
+ board.addEventListener('contextmenu',e=>{
91
+ e.preventDefault();
92
+ + if(paused||over||won)return;
93
+ + const g=getCell(e);if(g)toggleFlag(g.row,g.col);
94
+ });
95
+
96
+ board.addEventListener('mousedown',e=>{
97
+ diff --git a/games/puzzle15/index.html b/games/puzzle15/index.html
98
+ index 42f278e..5b0fa02 100644
99
+ --- a/games/puzzle15/index.html
100
+ +++ b/games/puzzle15/index.html
101
+ @@ -188,7 +188,7 @@ h1{
102
+ function findTile(val){
103
+ for(let r=0;r<gridSize;r++)
104
+ for(let c=0;c<gridSize;c++)
105
+ - if(grid[r][c]===val)return{r,c};
106
+ + if(grid[r][c]===val)return{row:r,col:c};
107
+ return null;
108
+ }
109
+
110
+ diff --git a/games/snake/index.html b/games/snake/index.html
111
+ index 2f6919e..af005eb 100644
112
+ --- a/games/snake/index.html
113
+ +++ b/games/snake/index.html
114
+ @@ -166,8 +166,7 @@ canvas{display:block;width:100%;height:100%;image-rendering:pixelated;touch-acti
115
+
116
+ // ---- DIRECTIONS ----
117
+ const UP={x:0,y:-1},DOWN={x:0,y:1},LEFT={x:-1,y:0},RIGHT={x:1,y:0};
118
+ - const opposite={};
119
+ - opposite[UP]=DOWN;opposite[DOWN]=UP;opposite[LEFT]=RIGHT;opposite[RIGHT]=LEFT;
120
+ + const opposite=new Map([[UP,DOWN],[DOWN,UP],[LEFT,RIGHT],[RIGHT,LEFT]]);
121
+
122
+ // ---- PARTICLES ----
123
+ let particles=[];
124
+ @@ -684,8 +683,8 @@ canvas{display:block;width:100%;height:100%;image-rendering:pixelated;touch-acti
125
+
126
+ // ---- INPUT ----
127
+ function setDirection(dir){
128
+ - if(!opposite[dir])return;
129
+ - if(dir===opposite[nextDirection])return;
130
+ + if(!opposite.has(dir))return;
131
+ + if(dir===opposite.get(direction))return;
132
+ nextDirection=dir;
133
+ }
134
+
135
+ diff --git a/games/space/index.html b/games/space/index.html
136
+ index b980c91..d567d68 100644
137
+ --- a/games/space/index.html
138
+ +++ b/games/space/index.html
139
+ @@ -1113,13 +1113,6 @@ function setupTouch(c){
140
+ });
141
+ }
142
+
143
+ -canvas.addEventListener('mousemove',e=>{
144
+ -if(game.state!=='playing'||paused||!game.player.alive)return;
145
+ -const rect=canvas.getBoundingClientRect();
146
+ -const x=(e.clientX-rect.left)/canvas.clientWidth*W;
147
+ -game.player.x=clamp(x,game.player.w/2+5,W-game.player.w/2-5);
148
+ -});
149
+ -
150
+ document.addEventListener('visibilitychange',()=>{
151
+ if(document.hidden&&game&&game.state==='playing')paused=true;
152
+ });
153
+ @@ -1152,6 +1145,12 @@ function loop(){update();draw();rafId=requestAnimationFrame(loop)}
154
+ canvas=document.getElementById('c');
155
+ canvas.width=W;canvas.height=H;
156
+ ctx=canvas.getContext('2d');
157
+ +canvas.addEventListener('mousemove',e=>{
158
+ +if(game.state!=='playing'||paused||!game.player.alive)return;
159
+ +const rect=canvas.getBoundingClientRect();
160
+ +const x=(e.clientX-rect.left)/canvas.clientWidth*W;
161
+ +game.player.x=clamp(x,game.player.w/2+5,W-game.player.w/2-5);
162
+ +});
163
+ setupTouch(canvas);
164
+ try{audio.init()}catch(e){}
165
+ resize();
166
+ diff --git a/games/typing/index.html b/games/typing/index.html
167
+ index 17bf35b..bad31b2 100644
168
+ --- a/games/typing/index.html
169
+ +++ b/games/typing/index.html
170
+ @@ -634,6 +634,7 @@ document.getElementById('pauseOverlay').addEventListener('click',e=>{if(e.target
171
+ // ===== Keyboard =====
172
+ document.addEventListener('keydown',e=>{
173
+ if(e.repeat)return;const k=e.key;
174
+ +if(k!=='Escape'&&(e.target.isContentEditable||/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)))return;
175
+ if((k==='p'||k==='P'||k==='Escape')&&isRunning){e.preventDefault();if(isPaused)resumeGame();else pauseGame();return}
176
+ if(k==='m'||k==='M'){e.preventDefault();if(!audioCtx)initAudio();toggleMute();return}
177
+ if((k===' '||k==='Enter')&&!isRunning){e.preventDefault();if(!audioCtx)initAudio();startGame();return}