Import Cobalt 25.master.0.1033734
diff --git a/third_party/skia/demos.skia.org/demos/hello_world/index.html b/third_party/skia/demos.skia.org/demos/hello_world/index.html
new file mode 100644
index 0000000..5a0a9ca
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/hello_world/index.html
@@ -0,0 +1,42 @@
+<!DOCTYPE html>
+<title>Hello World Demo</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<script type="text/javascript" src="https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/canvaskit.js"></script>
+
+<style>
+  canvas {
+    border: 1px dashed grey;
+  }
+</style>
+
+<body>
+  <h1>Hello world</h1>
+
+  <canvas id=draw width=500 height=500></canvas>
+</body>
+
+<script type="text/javascript" charset="utf-8">
+  const ckLoaded = CanvasKitInit({ locateFile: (file) => 'https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/' + file });
+
+  ckLoaded.then((CanvasKit) => {
+    const surface = CanvasKit.MakeCanvasSurface('draw');
+    if (!surface) {
+      throw 'Could not make surface';
+    }
+
+    const paint = new CanvasKit.Paint();
+    paint.setColor(CanvasKit.RED);
+
+    const textPaint = new CanvasKit.Paint();
+    const textFont = new CanvasKit.Font(null, 20);
+
+    function drawFrame(canvas) {
+      canvas.drawRect(CanvasKit.LTRBRect(10, 10, 50, 50), paint);
+      canvas.drawText('If you see this, CanvasKit loaded!!', 5, 100, textPaint, textFont);
+    }
+    surface.requestAnimationFrame(drawFrame);
+  });
+
+</script>
\ No newline at end of file
diff --git a/third_party/skia/demos.skia.org/demos/image_decode_web_worker/index.html b/third_party/skia/demos.skia.org/demos/image_decode_web_worker/index.html
new file mode 100644
index 0000000..36636cb
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/image_decode_web_worker/index.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<title>Image Decoding Demo</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
+<style>
+  canvas {
+    border: 1px dashed grey;
+  }
+
+  .canvas-container {
+    float: left;
+  }
+</style>
+
+<body>
+  <h1>CanvasKit loading images in a webworker (using browser-based decoders)</h1>
+  <p>NOTE: this demo currently only works in chromium-based browsers, where
+    <a href="https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas#Browser_compatibility">
+      Offscreen Canvas
+    </a>
+    is supported.
+  </p>
+
+  <div class="canvas-container">
+    <h2>Decoding on main thread</h2>
+    <canvas id="main-thread-canvas" width=500 height=500></canvas>
+    <div>
+      <button id="load-button-main">Decode Image on Main Thread</button>
+      <button id="load-button-web">Decode Image with Web Worker</button>
+      <button id="clear-button">Clear Image</button>
+    </div>
+    <p>
+      Notice that decoding the image on the main thread pauses the circle animation until the
+      image is ready to be drawn, where as decoding it in a webworker does not have this pause
+      (or at least not as drastic a pause). You may want to reload the page, as browsers are
+      smart enough to not have to re-decode the image on subsequent requests.
+    </p>
+  </div>
+
+</body>
+<script type="text/javascript" src="https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/canvaskit.js"></script>
+<script type="text/javascript" src="main.js"></script>
\ No newline at end of file
diff --git a/third_party/skia/demos.skia.org/demos/image_decode_web_worker/main.js b/third_party/skia/demos.skia.org/demos/image_decode_web_worker/main.js
new file mode 100644
index 0000000..aef94bc
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/image_decode_web_worker/main.js
@@ -0,0 +1,82 @@
+// Inspired by https://gist.github.com/ahem/d19ee198565e20c6f5e1bcd8f87b3408
+const worker = new Worker('worker.js');
+
+const canvasKitInitPromise =
+    CanvasKitInit({locateFile: (file) => 'https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/'+file});
+
+const bigImagePromise =
+    fetch('https://upload.wikimedia.org/wikipedia/commons/3/30/Large_Gautama_Buddha_statue_in_Buddha_Park_of_Ravangla%2C_Sikkim.jpg')
+    .then((response) => response.blob());
+
+Promise.all([
+    canvasKitInitPromise,
+    bigImagePromise
+]).then(([
+    CanvasKit,
+    imageBlob
+]) => {
+    const surface = CanvasKit.MakeWebGLCanvasSurface('main-thread-canvas', null);
+    if (!surface) {
+        throw 'Could not make main thread canvas surface';
+    }
+
+    const paint = new CanvasKit.Paint();
+    paint.setColor(CanvasKit.RED);
+
+    let decodedImage;
+    // This animation draws a red circle oscillating from the center of the canvas.
+    // It is there to show the lag introduced by decoding the image on the main
+    // thread.
+    const drawFrame = (canvas) => {
+        canvas.clear(CanvasKit.WHITE);
+
+        if (decodedImage) {
+            canvas.drawImageRect(decodedImage,
+                CanvasKit.LTRBRect(0, 0, 3764, 5706), // original size of the image
+                CanvasKit.LTRBRect(0, 0, 500, 800), // scaled down
+                null); // no paint needed
+        }
+        canvas.drawCircle(250, 250, 200 * Math.abs(Math.sin(Date.now() / 1000)), paint);
+        surface.requestAnimationFrame(drawFrame);
+    };
+    surface.requestAnimationFrame(drawFrame);
+
+
+    document.getElementById('load-button-main').addEventListener('click', () => {
+        if (decodedImage) {
+            decodedImage.delete();
+            decodedImage = null;
+        }
+        const imgBitmapPromise = createImageBitmap(imageBlob);
+        imgBitmapPromise.then((imgBitmap) => {
+            decodedImage = CanvasKit.MakeImageFromCanvasImageSource(imgBitmap);
+        });
+    });
+
+    document.getElementById('load-button-web').addEventListener('click', () => {
+        if (decodedImage) {
+            decodedImage.delete();
+            decodedImage = null;
+        }
+        worker.postMessage(imageBlob);
+    });
+    worker.addEventListener('message', (e) => {
+        const decodedBuffer = e.data.decodedArrayBuffer;
+        const pixels = new Uint8Array(decodedBuffer);
+        decodedImage = CanvasKit.MakeImage({
+            width: e.data.width,
+            height: e.data.height,
+            alphaType: CanvasKit.AlphaType.Unpremul,
+            colorType: CanvasKit.ColorType.RGBA_8888,
+            colorSpace: CanvasKit.ColorSpace.SRGB
+        }, pixels, 4 * e.data.width);
+    });
+    document.getElementById('clear-button').addEventListener('click', () => {
+        if (decodedImage) {
+            decodedImage.delete();
+            decodedImage = null;
+        }
+    });
+});
+
+
diff --git a/third_party/skia/demos.skia.org/demos/image_decode_web_worker/worker.js b/third_party/skia/demos.skia.org/demos/image_decode_web_worker/worker.js
new file mode 100644
index 0000000..c5e17c3
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/image_decode_web_worker/worker.js
@@ -0,0 +1,23 @@
+// This worker listens for a message that is the blob of data that is an encoded image.
+// In principle, this worker could also load the image, but I didn't want to introduce
+// network lag in this comparison. When it has decoded the image and converted it into
+// unpremul bytes, it returns the width, height, and the pixels in an array buffer via
+// a worker message.
+self.addEventListener('message', (e) => {
+    const blob = e.data;
+    createImageBitmap(blob).then((bitmap) => {
+        const oCanvas = new OffscreenCanvas(bitmap.width, bitmap.height);
+        const ctx2d = oCanvas.getContext('2d');
+        ctx2d.drawImage(bitmap, 0, 0);
+
+        const imageData = ctx2d.getImageData(0, 0, bitmap.width, bitmap.height);
+        const arrayBuffer = imageData.data.buffer;
+        self.postMessage({
+            width: bitmap.width,
+            height: bitmap.height,
+            decodedArrayBuffer: arrayBuffer
+        }, [
+          arrayBuffer // give up ownership of this object
+        ]);
+    });
+});
\ No newline at end of file
diff --git a/third_party/skia/demos.skia.org/demos/image_sampling/index.html b/third_party/skia/demos.skia.org/demos/image_sampling/index.html
new file mode 100644
index 0000000..6626630
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/image_sampling/index.html
@@ -0,0 +1,143 @@
+<!DOCTYPE html>
+<title>Custom Image Upscaling</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<script type="text/javascript" src="https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/canvaskit.js"></script>
+
+<style>
+    figcaption {
+        max-width: 800px;
+    }
+</style>
+
+<body>
+  <h1>Custom Image Upscaling</h1>
+
+  <div class="slidecontainer">
+      <input type="range" min="0" max="1" value="0" step="0.01" class="slider" id="sharpen"
+             title="sharpen coefficient: 0 means nearest neighbor.">
+      <input type="range" min="0" max="1" value="0.3" step="0.01" class="slider" id="cubic_B"
+             title="cubic B">
+      <input type="range" min="0" max="1" value="0.3" step="0.01" class="slider" id="cubic_C"
+             title="cubic C">
+  </div>
+
+  <figure>
+    <canvas id=draw width=820 height=820></canvas>
+    <figcaption>
+        This demo shows off a custom image upscaling algorithm written in SkSL. The algorithm
+        can be between nearest neighbor and linear interpolation, depending if the value of the
+        sharpen (i.e. the first) slider is 0 or 1, respectively. The upper left quadrant shows
+        the results of a 100x zoom in on a 4 pixel by 4 pixel image of random colors with this
+        algorithm. The lower left is the same algorithm with a smoothing curve applied.
+        <br>
+        For comparison, the upper right shows a stock linear interpolation and the lower right
+        shows a cubic interpolation with the B and C values controlled by the two remaining
+        sliders.
+    </figcaption>
+  </figure>
+
+</body>
+
+<script type="text/javascript" charset="utf-8">
+  const ckLoaded = CanvasKitInit({ locateFile: (file) => 'https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/' + file });
+
+  ckLoaded.then((CanvasKit) => {
+    if (!CanvasKit.RuntimeEffect) {
+        throw 'Need RuntimeEffect';
+    }
+    const surface = CanvasKit.MakeCanvasSurface('draw');
+    if (!surface) {
+      throw 'Could not make surface';
+    }
+
+    const prog = `
+    uniform shader image;
+    uniform float  sharp;  // 1/m    0 --> NN, 1 --> Linear
+    uniform float  do_smooth;   // bool
+
+    float2 smooth(float2 t) {
+        return t * t * (3.0 - 2.0 * t);
+    }
+
+    float2 sharpen(float2 w) {
+        return saturate(sharp * (w - 0.5) + 0.5);
+    }
+
+    half4 main(float2 p) {
+        half4 pa = image.eval(float2(p.x-0.5, p.y-0.5));
+        half4 pb = image.eval(float2(p.x+0.5, p.y-0.5));
+        half4 pc = image.eval(float2(p.x-0.5, p.y+0.5));
+        half4 pd = image.eval(float2(p.x+0.5, p.y+0.5));
+        float2 w = sharpen(fract(p + 0.5));
+        if (do_smooth > 0) {
+            w = smooth(w);
+        }
+      return mix(mix(pa, pb, w.x), mix(pc, pd, w.x), w.y);
+    }
+    `;
+    const effect = CanvasKit.RuntimeEffect.Make(prog);
+
+    const paint = new CanvasKit.Paint();
+    // image is a 4x4 image of 16 random colors. This very small image will be upscaled
+    // through various techniques.
+    const image = function() {
+        const surf = CanvasKit.MakeSurface(4, 4);
+        const c = surf.getCanvas();
+        for (let y = 0; y < 4; y++) {
+            for (let x = 0; x < 4; x++) {
+                paint.setColor([Math.random(), Math.random(), Math.random(), 1]);
+                c.drawRect(CanvasKit.LTRBRect(x, y, x+1, y+1), paint);
+            }
+        }
+        return surf.makeImageSnapshot();
+    }();
+
+    const imageShader = image.makeShaderOptions(CanvasKit.TileMode.Clamp,
+                                                CanvasKit.TileMode.Clamp,
+                                                CanvasKit.FilterMode.Nearest,
+                                                CanvasKit.MipmapMode.None);
+
+    sharpen.oninput = () => { surface.requestAnimationFrame(drawFrame); };
+    cubic_B.oninput = () => { surface.requestAnimationFrame(drawFrame); };
+    cubic_C.oninput = () => { surface.requestAnimationFrame(drawFrame); };
+
+    const drawFrame = function(canvas) {
+        const v = sharpen.valueAsNumber;
+        const m = 1/Math.max(v, 0.00001);
+        const B = cubic_B.valueAsNumber;
+        const C = cubic_C.valueAsNumber;
+
+        canvas.save();
+        // Upscale all drawing by 100x; This is big enough to make the differences in technique
+        // more obvious.
+        const scale = 100;
+        canvas.scale(scale, scale);
+
+        // Upper left, draw image using an algorithm (written in SkSL) between nearest neighbor and
+        // linear interpolation with no smoothing.
+        paint.setShader(effect.makeShaderWithChildren([m, 0], [imageShader], null));
+        canvas.drawRect(CanvasKit.LTRBRect(0, 0, 4, 4), paint);
+
+        // Lower left, draw image using an algorithm (written in SkSL) between nearest neighbor and
+        // linear interpolation with smoothing enabled.
+        canvas.save();
+        canvas.translate(0, 4.1);
+        paint.setShader(effect.makeShaderWithChildren([m, 1], [imageShader], null));
+        canvas.drawRect(CanvasKit.LTRBRect(0, 0, 4, 4), paint);
+        canvas.restore();
+
+        // Upper right, draw image with built-in linear interpolation.
+        canvas.drawImageOptions(image, 4.1, 0, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None, null);
+
+        // Lower right, draw image with configurable cubic interpolation.
+        canvas.drawImageCubic(image, 4.1, 4.1, B, C, null);
+
+        canvas.restore();
+    };
+
+    surface.requestAnimationFrame(drawFrame);
+  });
+
+</script>
diff --git a/third_party/skia/demos.skia.org/demos/path_performance/garbage.svg b/third_party/skia/demos.skia.org/demos/path_performance/garbage.svg
new file mode 100644
index 0000000..7ccc45a
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/path_performance/garbage.svg
@@ -0,0 +1,21 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
+  <path
+      fill="#3A5"
+      d="
+        M 7, 50
+        a 43,43 0 1,0 86,0
+        a 43,43 0 1,0 -86,0
+      "
+  />
+  <path
+      d="
+        M 53, 23
+        a 6,6 0 1,0 12,0
+        a 6,6 0 1,0 -12,0
+      "
+  />
+  <path d="M36,36c5,0,3,2,8-1c1,2,1,3,3,2c3,0-6,7-3,8c-4-2-9,2-14-2c4-3,4-4,5-7c5,0,8,2,12,1"/>
+  <path fill="#000" d="M34,29h31c2,5,7,10,7,16l-8,1l8,1l-3,31l-5,-18l-11,18l5-34l-3-8z"/>
+  <path stroke-width="2" d="M27,48h23M28,49h21l-3,28h-14l-4,-28h5l3,28h3v-28h5l-2,28m3-4h-13m-1-5h16m0-5h-16m-1-5h18m0-5h-19"/>
+  <path stroke="#F00" stroke-width="1"/>
+</svg>
diff --git a/third_party/skia/demos.skia.org/demos/path_performance/index.html b/third_party/skia/demos.skia.org/demos/path_performance/index.html
new file mode 100644
index 0000000..b96d465
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/path_performance/index.html
@@ -0,0 +1,97 @@
+<!DOCTYPE html>
+<title>CanvasKit Path Rendering Performance Demo</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
+<style>
+  html, body {
+    max-width: 700px;
+  }
+
+  #result-container {
+    border: 1px dashed grey;
+    height: 500px;
+    width: 500px;
+    position: relative;
+    overflow: hidden;
+  }
+
+  canvas {
+    visibility: hidden;
+    position: absolute;
+    left: 0;
+    top: 0;
+  }
+  object {
+    visibility: hidden;
+    position: absolute;
+    height: 500px;
+    width: 500px;
+    left: 0;
+    top: 0;
+  }
+
+  th {
+    text-align: left;
+    width: 33%;
+  }
+  td {
+    padding: 12px;
+    color: #555;
+    font-style: italic;
+    height: 80px;
+  }
+  table {
+    width: 100%;
+  }
+</style>
+
+<body>
+  <h1>CanvasKit Path Rendering Performance Demo</h1>
+  <p>NOTE: this demo currently only works in chromium-based browsers, where
+    <a href="https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas#Browser_compatibility">
+      Offscreen Canvas
+    </a>
+    is supported.
+  </p>
+
+
+  <h2>1. Choose a rendering method</h2>
+  <table>
+    <tr>
+      <th>
+        <input type="radio" id="SVG-input" name="rendermethod" checked>
+        <label for="SVG-input">SVG</label>
+      </th>
+      <th>
+        <input type="radio" id="Path2D-input" name="rendermethod">
+        <label for="Path2D-input">
+          <a href="https://developer.mozilla.org/en-US/docs/Web/API/Path2D">Path2D API</a>
+        </label>
+      </th>
+      <th>
+        <input type="radio" id="CanvasKit-input" name="rendermethod">
+        <label for="CanvasKit-input">CanvasKit</label>
+      </th>
+    </tr>
+    <tr>
+      <td id="SVG-fps">Choose this rendering method to collect data on its performance...</td>
+      <td id="Path2D-fps">Choose this rendering method to collect data on its performance...</td>
+      <td id="CanvasKit-fps">Choose this rendering method to collect data on its performance...</td>
+    </tr>
+  </table>
+
+  <h2>2. View the result</h2>
+  <div id="result-container">
+    <!-- Arbitrary svg for testing. Source: https://dev.w3.org/SVG/tools/svgweb/samples/svg-files-->
+    <object type="image/svg+xml" id="svg">
+      Garbage pictograph
+    </object>
+    <canvas id="Path2D-canvas" height=500 width=500></canvas>
+    <canvas id="CanvasKit-canvas" height=500 width=500></canvas>
+  </div>
+</body>
+<script type="text/javascript" src="https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/canvaskit.js"></script>
+<script type="text/javascript" src="shared.js"></script>
+<script type="text/javascript" src="main.js"></script>
diff --git a/third_party/skia/demos.skia.org/demos/path_performance/main.js b/third_party/skia/demos.skia.org/demos/path_performance/main.js
new file mode 100644
index 0000000..d618bfb
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/path_performance/main.js
@@ -0,0 +1,106 @@
+const DEFAULT_METHOD = 'SVG';
+
+const worker = new Worker('worker.js');
+
+const svgObjectElement = document.getElementById('svg');
+document.getElementById('svg').addEventListener('load', () => {
+
+    const svgElement = svgObjectElement.contentDocument;
+    const svgData = svgToPathStringAndFillColorPairs(svgElement);
+
+    // Send svgData and transfer an offscreenCanvas to the worker for Path2D and CanvasKit rendering
+    const path2dCanvas =
+        document.getElementById('Path2D-canvas').transferControlToOffscreen();
+    worker.postMessage({
+        svgData: svgData,
+        offscreenCanvas: path2dCanvas,
+        type: 'Path2D'
+    }, [path2dCanvas]);
+    const canvasKitCanvas =
+        document.getElementById('CanvasKit-canvas').transferControlToOffscreen();
+    worker.postMessage({
+        svgData: svgData,
+        offscreenCanvas: canvasKitCanvas,
+        type: 'CanvasKit'
+    }, [canvasKitCanvas]);
+
+    // The Canvas2D and CanvasKit rendering methods are executed in a web worker to avoid blocking
+    // the main thread. The SVG rendering method is executed in the main thread. SVG rendering is
+    // not in a worker because it is not possible - the DOM cannot be accessed from a web worker.
+    const svgAnimator = new Animator();
+    svgAnimator.renderer = new SVGRenderer(svgObjectElement);
+    switchRenderMethodCallback(DEFAULT_METHOD)();
+
+    // Listen to framerate reports from the worker, and update framerate text
+    worker.addEventListener('message', ({ data: {renderMethod, framesCount, totalFramesMs} }) => {
+        const fps = fpsFromFramesInfo(framesCount, totalFramesMs);
+        let textEl;
+        if (renderMethod === 'Path2D') {
+            textEl = document.getElementById('Path2D-fps');
+        }
+        if (renderMethod === 'CanvasKit') {
+            textEl = document.getElementById('CanvasKit-fps');
+        }
+        textEl.innerText = `${fps.toFixed(2)} fps over ${framesCount} frames`;
+    });
+    // Update framerate text every second
+    setInterval(() => {
+        if (svgAnimator.framesCount > 0) {
+            const fps = fpsFromFramesInfo(svgAnimator.framesCount, svgAnimator.totalFramesMs);
+            document.getElementById('SVG-fps').innerText =
+                `${fps.toFixed(2)} fps over ${svgAnimator.framesCount} frames`;
+        }
+    }, 1000);
+
+    document.getElementById('SVG-input')
+        .addEventListener('click', switchRenderMethodCallback('SVG'));
+    document.getElementById('Path2D-input')
+        .addEventListener('click', switchRenderMethodCallback('Path2D'));
+    document.getElementById('CanvasKit-input')
+        .addEventListener('click', switchRenderMethodCallback('CanvasKit'));
+
+    function switchRenderMethodCallback(switchMethod) {
+        return () => {
+            // Hide all renderer elements and stop svgAnimator
+            document.getElementById('CanvasKit-canvas').style.visibility = 'hidden';
+            document.getElementById('Path2D-canvas').style.visibility = 'hidden';
+            for (const svgEl of svgAnimator.renderer.svgElArray) {
+                svgEl.style.visibility = 'hidden';
+            }
+            svgAnimator.stop();
+
+            // Show only the active renderer element
+            if (switchMethod === 'SVG') {
+                svgAnimator.start();
+                for (const svgEl of svgAnimator.renderer.svgElArray) {
+                    svgEl.style.visibility = 'visible';
+                }
+            }
+            if (switchMethod === 'CanvasKit') {
+                document.getElementById('CanvasKit-canvas').style.visibility = 'visible';
+            }
+            if (switchMethod === 'Path2D') {
+                document.getElementById('Path2D-canvas').style.visibility = 'visible';
+            }
+            worker.postMessage({ switchMethod });
+        };
+    }
+});
+// Add .data after the load listener so that the listener always fires an event
+svgObjectElement.data = 'garbage.svg';
+
+const EMPTY_SVG_PATH_STRING = 'M 0 0';
+const COLOR_WHITE = '#000000';
+function svgToPathStringAndFillColorPairs(svgElement) {
+    const pathElements = Array.from(svgElement.getElementsByTagName('path'));
+    return pathElements.map((path) => [
+        path.getAttribute('d') ?? EMPTY_SVG_PATH_STRING,
+        path.getAttribute('fill') ?? COLOR_WHITE
+    ]);
+}
+
+const MS_IN_A_SECOND = 1000;
+function fpsFromFramesInfo(framesCount, totalFramesMs) {
+    const averageFrameTime = totalFramesMs / framesCount;
+    return (1 / averageFrameTime) * MS_IN_A_SECOND;
+}
diff --git a/third_party/skia/demos.skia.org/demos/path_performance/shared.js b/third_party/skia/demos.skia.org/demos/path_performance/shared.js
new file mode 100644
index 0000000..134ad04
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/path_performance/shared.js
@@ -0,0 +1,140 @@
+// Returns an [x, y] point on a circle, with given origin and radius, at a given angle
+// counter-clockwise from the positive horizontal axis.
+function circleCoordinates(origin, radius, radians) {
+    return [
+        origin[0] + Math.cos(radians) * radius,
+        origin[1] + Math.sin(radians) * radius
+    ];
+}
+
+// Animator handles calling and stopping requestAnimationFrame and keeping track of framerate.
+class Animator {
+    framesCount = 0;
+    totalFramesMs = 0;
+    animating = false;
+    renderer = null;
+
+    start() {
+        if (this.animating === false) {
+            this.animating = true;
+            this.framesCount = 0;
+            const frameStartMs = performance.now();
+
+            const drawFrame = () => {
+                if (this.animating && this.renderer) {
+                    requestAnimationFrame(drawFrame);
+                    this.framesCount++;
+
+                    const [x, y] = circleCoordinates([-70, -70], 50, this.framesCount/100);
+                    this.renderer.render(x, y);
+
+                    const frameTimeMs = performance.now() - frameStartMs;
+                    this.totalFramesMs = frameTimeMs;
+                }
+            };
+            requestAnimationFrame(drawFrame);
+        }
+    }
+
+    stop() {
+        this.animating = false;
+    }
+}
+
+// The following three renderers draw a repeating pattern of paths.
+// The approximate height and width of this repeated pattern is given by PATTERN_BOUNDS:
+const PATTERN_BOUNDS = 600;
+// And the spacing of the pattern (distance between repeated paths) is given by PATTERN_SPACING:
+const PATTERN_SPACING = 70;
+
+class SVGRenderer {
+    constructor(svgObjectElement) {
+        this.svgObjectElement = svgObjectElement;
+        this.svgElArray = [];
+        // Create an SVG element for every position in the pattern
+        for (let xo = 0; xo < PATTERN_BOUNDS; xo += PATTERN_SPACING) {
+            for (let yo = 0; yo < PATTERN_BOUNDS; yo += PATTERN_SPACING) {
+                const clonedSVG = svgObjectElement.cloneNode(true);
+                this.svgElArray.push(clonedSVG);
+                svgObjectElement.parentElement.appendChild(clonedSVG);
+            }
+        }
+    }
+
+    render(x, y) {
+        let i = 0;
+        for (let xo = 0; xo < PATTERN_BOUNDS; xo += PATTERN_SPACING) {
+            for (let yo = 0; yo < PATTERN_BOUNDS; yo += PATTERN_SPACING) {
+                this.svgElArray[i].style.transform = `translate(${x + xo}px, ${y + yo}px)`;
+                i++;
+            }
+        }
+    }
+}
+
+class Path2dRenderer {
+    constructor(svgData, offscreenCanvas) {
+        this.data = svgData.map(([pathString, fillColor]) => [new Path2D(pathString), fillColor]);
+
+        this.ctx = offscreenCanvas.getContext('2d');
+    }
+
+    render(x, y) {
+        const ctx = this.ctx;
+
+        ctx.clearRect(0, 0, 500, 500);
+
+        for (let xo = 0; xo < PATTERN_BOUNDS; xo += PATTERN_SPACING) {
+            for (let yo = 0; yo < PATTERN_BOUNDS; yo += PATTERN_SPACING) {
+                ctx.save();
+                ctx.translate(x + xo, y + yo);
+
+                for (const [path, fillColor] of this.data) {
+                    ctx.fillStyle = fillColor;
+                    ctx.fill(path);
+                }
+                ctx.restore();
+            }
+        }
+    }
+}
+
+class CanvasKitRenderer {
+    constructor(svgData, offscreenCanvas, CanvasKit) {
+        this.CanvasKit = CanvasKit;
+        this.data = svgData.map(([pathString, fillColor]) => [
+            CanvasKit.Path.MakeFromSVGString(pathString),
+            CanvasKit.parseColorString(fillColor)
+        ]);
+
+        this.surface = CanvasKit.MakeWebGLCanvasSurface(offscreenCanvas, null);
+        if (!this.surface) {
+            throw 'Could not make canvas surface';
+        }
+        this.canvas = this.surface.getCanvas();
+
+        this.paint = new CanvasKit.Paint();
+        this.paint.setAntiAlias(true);
+        this.paint.setStyle(CanvasKit.PaintStyle.Fill);
+    }
+
+    render(x, y) {
+        const canvas = this.canvas;
+
+        canvas.clear(this.CanvasKit.WHITE);
+
+        for (let xo = 0; xo < PATTERN_BOUNDS; xo += PATTERN_SPACING) {
+            for (let yo = 0; yo < PATTERN_BOUNDS; yo += PATTERN_SPACING) {
+                canvas.save();
+                canvas.translate(x + xo, y + yo);
+
+                for (const [path, color] of this.data) {
+                    this.paint.setColor(color);
+                    canvas.drawPath(path, this.paint);
+                }
+                canvas.restore();
+            }
+        }
+        this.surface.flush();
+    }
+}
diff --git a/third_party/skia/demos.skia.org/demos/path_performance/worker.js b/third_party/skia/demos.skia.org/demos/path_performance/worker.js
new file mode 100644
index 0000000..692c055
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/path_performance/worker.js
@@ -0,0 +1,54 @@
+importScripts('https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/canvaskit.js');
+importScripts('shared.js');
+
+const CanvasKitPromise =
+    CanvasKitInit({locateFile: (file) => 'https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/'+file});
+
+const path2dAnimator = new Animator();
+const canvasKitAnimator = new Animator();
+addEventListener('message', async ({ data: {svgData, offscreenCanvas, type, switchMethod} }) => {
+    // The worker expect to receive 2 messages after initialization: One with an offscreenCanvas
+    // for Path2D rendering, and one with an offscreenCanvas for CanvasKit rendering.
+    if (svgData && offscreenCanvas && type) {
+        if (type === 'Path2D') {
+            path2dAnimator.renderer =
+                new Path2dRenderer(svgData, offscreenCanvas);
+        }
+        if (type === 'CanvasKit') {
+            const CanvasKit = await CanvasKitPromise;
+            canvasKitAnimator.renderer =
+                new CanvasKitRenderer(svgData, offscreenCanvas, CanvasKit);
+        }
+    }
+    // The worker receives a "switchMethod" message whenever the user clicks a rendering
+    // method button on the web page.
+    if (switchMethod) {
+        // Pause other renderers and start correct renderer
+        canvasKitAnimator.stop();
+        path2dAnimator.stop();
+
+        if (switchMethod === 'Path2D') {
+            path2dAnimator.start();
+        } else if (switchMethod === 'CanvasKit') {
+            canvasKitAnimator.start();
+        }
+    }
+});
+
+// Report framerates of Path2D and CanvasKit rendering back to main.js
+setInterval(() => {
+    if (path2dAnimator.framesCount > 0) {
+        postMessage({
+            renderMethod: 'Path2D',
+            framesCount: path2dAnimator.framesCount,
+            totalFramesMs: path2dAnimator.totalFramesMs
+        });
+    }
+    if (canvasKitAnimator.framesCount > 0) {
+        postMessage({
+            renderMethod: 'CanvasKit',
+            framesCount: canvasKitAnimator.framesCount,
+            totalFramesMs: canvasKitAnimator.totalFramesMs
+        });
+    }
+}, 1000);
diff --git a/third_party/skia/demos.skia.org/demos/sampling_types/index.html b/third_party/skia/demos.skia.org/demos/sampling_types/index.html
new file mode 100644
index 0000000..c9f35c7
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/sampling_types/index.html
@@ -0,0 +1,195 @@
+<!DOCTYPE html>
+<title>Image sampling techniques</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<script type="text/javascript" src="https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/canvaskit.js"></script>
+
+<style>
+    figcaption {
+        max-width: 800px;
+    }
+</style>
+
+<body>
+  <h1>Image sampling techniques</h1>
+
+  <div class="slidecontainer">
+      <input style="width:400px;" type="range" min="-10" max="10" value="0" step="0.01"
+             class="slider" id="scale_slider" title="Scale">
+  </div>
+
+  <figure>
+      <div ondrop="dropHandler(event);" ondragover="dragOverHandler(event);">
+          <canvas id="draw" width=2048 height=1600></canvas>
+      </div>
+    <figcaption>
+        Drop an Image onto the page (above)
+    </figcaption>
+  </figure>
+
+</body>
+
+<script type="text/javascript" charset="utf-8">
+    function preventScrolling(elem) {
+        elem.addEventListener('touchmove', (e) => {
+        // Prevents touch events in the element from scrolling.
+        e.preventDefault();
+        e.stopPropagation();
+      });
+    }
+
+    function dragOverHandler(ev) { ev.preventDefault(); }
+
+    // these can be changed with click/drag on the primary rectangle
+    let cell_width = 256,
+        cell_height = 256;
+
+    let image = null;
+    let CK = null;
+    let surface = null;
+    let drawFrame = null;
+
+    function dropHandler(ev) {
+      ev.preventDefault();
+
+      let file = null;
+      if (ev.dataTransfer.items) {
+        // Use DataTransferItemList interface to access the file(s)
+        for (item of ev.dataTransfer.items) {
+          // If dropped items aren't files, reject them
+          if (item.kind === 'file') {
+            file = item.getAsFile();
+            break;
+          }
+        }
+      } else {
+        // Use DataTransfer interface to access the file(s)
+        for (f of ev.dataTransfer.files) {
+          file = f;
+          break;
+        }
+      }
+      if (file) {
+          file.arrayBuffer().then(buffer => {
+              image = CK.MakeImageFromEncoded(buffer);
+              surface.requestAnimationFrame(drawFrame);
+          });
+      }
+    }
+
+  const ckLoaded = CanvasKitInit({ locateFile: (file) => 'https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/' + file });
+
+  ckLoaded.then((_CK) => {
+    CK = _CK;
+    surface = CK.MakeCanvasSurface('draw');
+    if (!surface) {
+      throw 'Could not make surface';
+    }
+
+    const font = new CK.Font(null, 15);
+    const paint = new CK.Paint();
+    const textPaint = new CK.Paint();
+    const TX = 20,
+          TY = 30;  // where we start the first rectangle
+
+    scale_slider.oninput = () => { surface.requestAnimationFrame(drawFrame); };
+
+    m33_scaled = function(sx, sy) {
+        if (!sy) sy = sx;
+        let m = new Float32Array(9);
+        m[0] = sx; m[4] = sy; m[8] = 1;
+        return m;
+    }
+
+    drawFrame = function(canvas) {
+        if (!image) return;
+
+        const scale = scale_slider.valueAsNumber < 0 ? 1 / (1 - scale_slider.valueAsNumber)
+                                                     : (1 + scale_slider.valueAsNumber);
+
+        const bounds = [0, 0, cell_width, cell_height];
+
+        const lm = m33_scaled(scale, scale);
+
+       const samplings = [
+            [CK.FilterMode.Nearest, CK.MipmapMode.None,   0, 0, "Nearest"],
+            [CK.FilterMode.Linear,  CK.MipmapMode.None,   0, 0, "Bilerp"],
+            [CK.FilterMode.Linear,  CK.MipmapMode.Linear, 0, 0, "Trilerp"],
+            [null, null,     0,   0.5, "CatmullRom"],
+            [null, null, 1/3.0, 1/3.0, "Mitchell"],
+        ];
+
+        const tile = CK.TileMode.Repeat;
+
+        canvas.save();
+        canvas.translate(TX, TY);
+        canvas.save();
+        for (i in samplings) {
+            if (i == 3) {
+                canvas.restore();
+                canvas.translate(0, bounds[3] - bounds[1] + 30);
+                canvas.save();
+            }
+
+            const s = samplings[i];
+            const shader = s[0] ? image.makeShaderOptions(tile, tile, s[0], s[1], lm)
+                                : image.makeShaderCubic(  tile, tile, s[2], s[3], lm);
+            paint.setShader(shader);
+            canvas.drawRect(bounds, paint);
+            shader.delete();
+
+            canvas.drawText(s[4], 20, -8, textPaint, font);
+
+            canvas.translate(bounds[2] - bounds[0] + 30, 0);
+        }
+        canvas.restore();
+        canvas.restore();
+        // draw the drag handle
+        if (true) {
+            canvas.save();
+            paint.setShader(null);
+            paint.setColor(CK.Color4f(1, 0, 0));
+            paint.setStrokeWidth(2);
+            canvas.translate(TX + cell_width + 4, TY + cell_height + 4);
+            canvas.drawLine(-12,   0, 0, 0, paint);
+            canvas.drawLine(  0, -12, 0, 0, paint);
+            canvas.restore()
+        }
+    };
+
+    // register our mouse handler
+    {
+        function len2(x, y) {
+            return x*x + y*y;
+        }
+        function hit_test(x,y, x1,y1) {
+            return len2(x-x1, y-y1) <= 10*10;
+        }
+
+        let do_drag = false;
+        function pointer_up(e) {
+            do_drag = false;
+         }
+        function pointer_down(e) {
+            do_drag = hit_test(TX+cell_width, TY+cell_height, e.offsetX, e.offsetY);
+         }
+        function pointer_move(e) {
+          if (e.pressure && do_drag) {
+              cell_width  = Math.max(e.offsetX - TX, 32);
+              cell_height = Math.max(e.offsetY - TY, 32);
+              surface.requestAnimationFrame(drawFrame);
+           }
+        }
+
+        const elem = document.getElementById('draw');
+        elem.addEventListener('pointermove', pointer_move);
+        elem.addEventListener('pointerdown', pointer_down);
+        elem.addEventListener('pointerup', pointer_up);
+        preventScrolling(elem);
+    }
+
+    surface.requestAnimationFrame(drawFrame);
+  });
+
+</script>
diff --git a/third_party/skia/demos.skia.org/demos/spreadsheet/index.html b/third_party/skia/demos.skia.org/demos/spreadsheet/index.html
new file mode 100644
index 0000000..0a5ce27
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/spreadsheet/index.html
@@ -0,0 +1,252 @@
+<!DOCTYPE html>
+<title>Spreadsheet Demo</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<script type="text/javascript" src="https://unpkg.com/canvaskit-wasm@0.33.0/bin/full/canvaskit.js"></script>
+
+<style>
+  body {
+      background-color: black;
+  }
+  h1 {
+      color: white;
+  }
+  .hidden {
+      display: none;
+  }
+</style>
+
+<body>
+  <h1>Large canvas with many numbers, like a spreadsheet</h1>
+  <select id="numbers_impl">
+    <option value="fillText">&lt;canvas&gt; fillText</option>
+    <option value="drawGlyphs">CK drawGlyphs (tuned)</option>
+    <option value="drawText">CK drawText (naive)</option>
+  </select>
+
+  <canvas id=ck_canvas width=3840 height=2160 class="hidden"></canvas>
+  <canvas id=canvas_2d width=3840 height=2160></canvas>
+</body>
+
+<script type="text/javascript" charset="utf-8">
+  const ckLoaded = CanvasKitInit({ locateFile: (file) => 'https://unpkg.com/canvaskit-wasm@0.33.0/bin/full/' + file });
+
+  // This is the dimensions of a 4k screen.
+  const WIDTH = 3840, HEIGHT = 2160;
+  const ROWS = 144;
+  const ROW_HEIGHT = 15;
+  const COLS = 77;
+  const COL_WIDTH = 50;
+  const canvas2DEle = document.getElementById('canvas_2d');
+  const ckEle = document.getElementById('ck_canvas');
+
+  ckLoaded.then((CanvasKit) => {
+    const canvas2dCtx = canvas2DEle.getContext('2d');
+    const surface = CanvasKit.MakeCanvasSurface('ck_canvas');
+    if (!surface) {
+      throw 'Could not make surface';
+    }
+
+    const colorPaints = {
+      "grey": CanvasKit.Color(76, 76, 76),
+      "black": CanvasKit.BLACK,
+      "white": CanvasKit.WHITE,
+      "springGreen": CanvasKit.Color(0, 255, 127),
+      "tomato": CanvasKit.Color(255, 99, 71),
+    };
+    for (const name in colorPaints) {
+      const color = colorPaints[name];
+      const paint = new CanvasKit.Paint();
+      paint.setColor(color);
+      colorPaints[name] = paint;
+    }
+
+    const data = [];
+    for (let row = 0; row < ROWS; row++) {
+      data[row] = [];
+      for (let col = 0; col < COLS; col++) {
+        data[row][col] = Math.random() * Math.PI;
+      }
+    }
+
+    // Maybe use https://storage.googleapis.com/skia-cdn/google-web-fonts/NotoSans-Regular.ttf
+    const textFont = new CanvasKit.Font(null, 12);
+
+    const choice = document.getElementById("numbers_impl");
+
+    let frames = [];
+    const framesToMeasure = 10;
+    choice.addEventListener("change", () => {
+      frames = [];
+      if (choice.selectedIndex === 0) {
+        canvas2DEle.classList.remove('hidden');
+        ckEle.classList.add('hidden');
+      } else {
+        canvas2DEle.classList.add('hidden');
+        ckEle.classList.remove('hidden');
+      }
+    })
+    function drawFrame(canvas) {
+      if (frames && frames.length === framesToMeasure) {
+        // It is important to measure frame to frame time to account for the time spent by the
+        // GPU after our flush calls.
+        const deltas = [];
+        for (let i = 0; i< frames.length-1;i++) {
+          deltas.push(frames[i+1] - frames[i]);
+        }
+        console.log(`First ${framesToMeasure} frames`, deltas);
+        console.log((frames[framesToMeasure-1] - frames[0]) / framesToMeasure);
+        frames = null;
+      } else if (frames) {
+        frames.push(performance.now());
+      }
+
+      if (choice.selectedIndex === 2) {
+        canvas.clear(CanvasKit.BLACK);
+        drawTextImpl(canvas);
+      } else if (choice.selectedIndex === 1) {
+        canvas.clear(CanvasKit.BLACK);
+        drawGlyphsImpl(canvas);
+      } else {
+        fillTextImpl(canvas2dCtx);
+      }
+
+      surface.requestAnimationFrame(drawFrame)
+    }
+
+    function drawTextImpl(canvas) {
+      const timer = performance.now() / 10000;
+      for (let row = 0; row < ROWS; row++) {
+        if (row % 2) {
+          canvas.drawRect(CanvasKit.XYWHRect(0, row * ROW_HEIGHT + 2, WIDTH, ROW_HEIGHT), colorPaints["grey"]);
+        }
+        for (let col = 0; col < COLS; col++) {
+          let n = Math.abs(Math.sin(timer + data[row][col]));
+          let useWhiteFont = true;
+          if (n < 0.05) {
+            canvas.drawRect(CanvasKit.XYWHRect(col * COL_WIDTH - 2, (row - 1) * ROW_HEIGHT + 2, COL_WIDTH, ROW_HEIGHT), colorPaints["tomato"]);
+            useWhiteFont = false;
+          } else if (n > 0.95) {
+            canvas.drawRect(CanvasKit.XYWHRect(col * COL_WIDTH - 2, (row - 1) * ROW_HEIGHT + 2, COL_WIDTH, ROW_HEIGHT), colorPaints["springGreen"]);
+            useWhiteFont = false;
+          }
+          const str = n.toFixed(4);
+          canvas.drawText(str, col * COL_WIDTH, row * ROW_HEIGHT,
+            useWhiteFont ? colorPaints["white"] : colorPaints["black"], textFont);
+        }
+      }
+    }
+
+    //====================================================================================
+    const alphabet = "0.123456789 ";
+    const glyphIDs = textFont.getGlyphIDs(alphabet);
+    // These are all 7 with current font and size
+    const advances = textFont.getGlyphWidths(glyphIDs);
+
+
+    const charsPerDataPoint = 6; // leading 0, period, 4 decimal places
+    const glyphIDsMObj = CanvasKit.MallocGlyphIDs(ROWS * COLS * charsPerDataPoint);
+    let wasmGlyphIDArr = glyphIDsMObj.toTypedArray();
+    const glyphLocationsMObj = CanvasKit.Malloc(Float32Array, glyphIDsMObj.length * 2);
+    let wasmGlyphLocations = glyphLocationsMObj.toTypedArray();
+
+    function dataToGlyphs(n, outputBuffer, offset) {
+      const s = n.toFixed(4);
+      outputBuffer[offset] = glyphIDs[0]; // Always a leading 0
+      outputBuffer[offset+1] = glyphIDs[1]; // Always a decimal place
+      for (let i = 2; i< charsPerDataPoint; i++) {
+        outputBuffer[offset+i] = glyphIDs[alphabet.indexOf(s[i])];
+      }
+    }
+    const spaceIndex = alphabet.length - 1;
+    function blankOut(outputBuffer, offset) {
+      for (let i = 0; i< charsPerDataPoint; i++) {
+        outputBuffer[offset+i] = glyphIDs[spaceIndex];
+      }
+    }
+
+    for (let row = 0; row < ROWS; row++) {
+      for (let col = 0; col < COLS; col++) {
+        for (let i = 0; i < charsPerDataPoint; i++) {
+          const offset = (col + row * COLS) * charsPerDataPoint * 2;
+          wasmGlyphLocations[offset + i * 2] = col * COL_WIDTH + i * advances[0];
+          wasmGlyphLocations[offset + i * 2 + 1] = row * ROW_HEIGHT;
+        }
+      }
+    }
+
+    const greyGlyphIDsMObj = CanvasKit.MallocGlyphIDs(charsPerDataPoint);
+
+    function drawGlyphsImpl(canvas) {
+      wasmGlyphIDArr = glyphIDsMObj.toTypedArray();
+      let wasmGreyGlyphIDsArr = greyGlyphIDsMObj.toTypedArray();
+
+      const timer = performance.now() / 10000;
+      for (let row = 0; row < ROWS; row++) {
+        if (row % 2) {
+          canvas.drawRect(CanvasKit.XYWHRect(0, row * ROW_HEIGHT + 2, WIDTH, ROW_HEIGHT), colorPaints["grey"]);
+        }
+        for (let col = 0; col < COLS; col++) {
+          const n = Math.abs(Math.sin(timer + data[row][col]));
+          let useWhiteFont = true;
+          if (n < 0.05) {
+            canvas.drawRect(CanvasKit.XYWHRect(col * COL_WIDTH - 2, (row - 1) * ROW_HEIGHT + 2, COL_WIDTH, ROW_HEIGHT), colorPaints["tomato"]);
+            useWhiteFont = false;
+          } else if (n > 0.95) {
+            canvas.drawRect(CanvasKit.XYWHRect(col * COL_WIDTH - 2, (row - 1) * ROW_HEIGHT + 2, COL_WIDTH, ROW_HEIGHT), colorPaints["springGreen"]);
+            useWhiteFont = false;
+          }
+
+          const offset = (col + row * COLS) * charsPerDataPoint;
+          if (useWhiteFont) {
+            dataToGlyphs(n, wasmGlyphIDArr, offset);
+          } else {
+            blankOut(wasmGlyphIDArr, offset);
+            dataToGlyphs(n, wasmGreyGlyphIDsArr, 0);
+            canvas.drawGlyphs(wasmGreyGlyphIDsArr,
+              glyphLocationsMObj.subarray(offset*2, (offset + charsPerDataPoint) * 2),
+              0, 0, textFont, colorPaints["grey"]
+            );
+          }
+        }
+      }
+      canvas.drawGlyphs(wasmGlyphIDArr, glyphLocationsMObj, 0, 0, textFont, colorPaints["white"]);
+    }
+
+    function fillTextImpl(ctx) {
+      ctx.font = '12px monospace';
+      ctx.fillStyle = 'black';
+      ctx.fillRect(0, 0, WIDTH, HEIGHT);
+      const timer = performance.now() / 10000;
+      for (let row = 0; row < ROWS; row++) {
+        if (row % 2) {
+          ctx.fillStyle = 'rgb(76,76,76)';
+          ctx.fillRect(0, row * ROW_HEIGHT + 2, WIDTH, ROW_HEIGHT);
+        }
+        for (let col = 0; col < COLS; col++) {
+          let n = Math.abs(Math.sin(timer + data[row][col]));
+          let useWhiteFont = true;
+          if (n < 0.05) {
+            ctx.fillStyle = 'rgb(255, 99, 71)';
+            ctx.fillRect(col * COL_WIDTH - 2, (row - 1) * ROW_HEIGHT + 2, COL_WIDTH, ROW_HEIGHT);
+            useWhiteFont = false;
+          } else if (n > 0.95) {
+            ctx.fillStyle = 'rgb(0, 255, 127)';
+            ctx.fillRect(col * COL_WIDTH - 2, (row - 1) * ROW_HEIGHT + 2, COL_WIDTH, ROW_HEIGHT);
+            useWhiteFont = false;
+          }
+          const str = n.toFixed(4);
+          if (useWhiteFont) {
+            ctx.fillStyle = 'white';
+          } else {
+            ctx.fillStyle = 'black';
+          }
+          ctx.fillText(str, col * COL_WIDTH, row * ROW_HEIGHT);
+        }
+      }
+    }
+
+    surface.requestAnimationFrame(drawFrame);
+  });
+</script>
\ No newline at end of file
diff --git a/third_party/skia/demos.skia.org/demos/textedit/index.html b/third_party/skia/demos.skia.org/demos/textedit/index.html
new file mode 100644
index 0000000..d6852c8
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/textedit/index.html
@@ -0,0 +1,165 @@
+<!DOCTYPE html>
+<title>TextEdit demo in CanvasKit</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<script type="text/javascript" src="https://particles.skia.org/dist/canvaskit.js"></script>
+<script type="text/javascript" src="textapi_utils.js"></script>
+<script type="text/javascript" src="spiralshader.js"></script>
+
+<style>
+canvas {
+  border: 1px dashed grey;
+}
+</style>
+
+<body>
+  <h1>TextEdit in CanvasKit</h1>
+
+  <canvas id=para2 width=600 height=600 tabindex='-1'></canvas>
+</body>
+
+<script type="text/javascript" charset="utf-8">
+    let CanvasKit;
+    onload = async () => {
+      CanvasKit = await CanvasKitInit({ locateFile: (file) => 'https://particles.skia.org/dist/'+file });
+      ParagraphAPI2();
+    };
+
+    function ParagraphAPI2() {
+      const surface = CanvasKit.MakeCanvasSurface('para2');
+      if (!surface) {
+        console.error('Could not make surface');
+        return;
+      }
+
+      const mouse = MakeMouse();
+      const cursor = MakeCursor(CanvasKit);
+      const canvas = surface.getCanvas();
+      const spiralEffect = MakeSpiralShaderEffect(CanvasKit);
+
+      const text0 = "In a hole in the ground there lived a hobbit. Not a nasty, dirty, " +
+                    "wet hole full of worms and oozy smells. This was a hobbit-hole and " +
+                    "that means good food, a warm hearth, and all the comforts of home.";
+      const LOC_X = 20,
+            LOC_Y = 20;
+
+      const bgPaint = new CanvasKit.Paint();
+      bgPaint.setColor([0.965, 0.965, 0.965, 1]);
+
+      const editor = MakeEditor(text0, {typeface:null, size:30}, cursor, 540);
+
+      editor.applyStyleToRange({size:130}, 0, 1);
+      editor.applyStyleToRange({italic:true}, 38, 38+6);
+      editor.applyStyleToRange({color:[1,0,0,1]}, 5, 5+4);
+
+      editor.setXY(LOC_X, LOC_Y);
+
+      function drawFrame(canvas) {
+        const lines = editor.getLines();
+
+        canvas.clear(CanvasKit.WHITE);
+
+        if (mouse.isActive()) {
+            const pos = mouse.getPos(-LOC_X, -LOC_Y);
+            const a = lines_pos_to_index(lines, pos[0], pos[1]);
+            const b = lines_pos_to_index(lines, pos[2], pos[3]);
+            if (a === b) {
+                editor.setIndex(a);
+            } else {
+                editor.setIndices(a, b);
+            }
+        }
+
+        canvas.drawRect(editor.bounds(), bgPaint);
+
+        {
+            // update our animated shaders
+            const rad_scale = Math.sin(Date.now() / 5000) / 2;
+            const shader0 = spiralEffect.makeShader([
+                rad_scale,
+                editor.width()/2, editor.width()/2,
+                1,0,0,1,                            // color0
+                0,0,1,1                             // color1
+             ]);
+            editor.draw(canvas, [shader0]);
+            shader0.delete();
+        }
+
+        surface.requestAnimationFrame(drawFrame);
+      }
+      surface.requestAnimationFrame(drawFrame);
+
+      function interact(e) {
+        const type = e.type;
+        if (type === 'pointerup') {
+            mouse.setUp(e.offsetX, e.offsetY);
+        } else if (type === 'pointermove') {
+            mouse.setMove(e.offsetX, e.offsetY);
+        } else if (type === 'pointerdown') {
+            mouse.setDown(e.offsetX, e.offsetY);
+        }
+      };
+
+      function keyhandler(e) {
+          switch (e.key) {
+              case 'ArrowLeft':  editor.moveDX(-1); return;
+              case 'ArrowRight': editor.moveDX(1); return;
+              case 'ArrowUp':
+                e.preventDefault();
+                editor.moveDY(-1);
+                return;
+              case 'ArrowDown':
+                e.preventDefault();
+                editor.moveDY(1);
+                return;
+            case 'Backspace':
+                editor.deleteSelection(-1);
+                return;
+            case 'Delete':
+                editor.deleteSelection(1);
+                return;
+              case 'Shift':
+                return;
+              case 'Tab':   // todo: figure out how to handle...
+                e.preventDefault();
+                return;
+            }
+            if (e.ctrlKey) {
+                e.preventDefault();
+                e.stopImmediatePropagation();
+                switch (e.key) {
+                    case 'r': editor.applyStyleToSelection({color:[1,0,0,1]}); return;
+                    case 'g': editor.applyStyleToSelection({color:[0,0.6,0,1]}); return;
+                    case 'u': editor.applyStyleToSelection({color:[0,0,1,1]}); return;
+                    case 'k': editor.applyStyleToSelection({color:[0,0,0,1]}); return;
+
+                    case 's': editor.applyStyleToSelection({shaderIndex:0}); return;
+
+                    case 'i': editor.applyStyleToSelection({italic:'toggle'}); return;
+                    case 'b': editor.applyStyleToSelection({bold:'toggle'}); return;
+                    case 'w': editor.applyStyleToSelection({wavy:'toggle'}); return;
+
+                    case ']': editor.applyStyleToSelection({size_add:1});  return;
+                    case '[': editor.applyStyleToSelection({size_add:-1}); return;
+                    case '}': editor.applyStyleToSelection({size_add:10});  return;
+                    case '{': editor.applyStyleToSelection({size_add:-10}); return;
+                }
+            }
+            if (!e.ctrlKey && !e.metaKey) {
+                if (e.key.length == 1) {  // avoid keys like "Escape" for now
+                    e.preventDefault();
+                    e.stopImmediatePropagation();
+                    editor.insert(e.key);
+                }
+            }
+      }
+
+      document.getElementById('para2').addEventListener('pointermove', interact);
+      document.getElementById('para2').addEventListener('pointerdown', interact);
+      document.getElementById('para2').addEventListener('pointerup', interact);
+      document.getElementById('para2').addEventListener('keydown', keyhandler);
+      return surface;
+    }
+
+</script>
diff --git a/third_party/skia/demos.skia.org/demos/textedit/spiralshader.js b/third_party/skia/demos.skia.org/demos/textedit/spiralshader.js
new file mode 100644
index 0000000..3ad0c37
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/textedit/spiralshader.js
@@ -0,0 +1,28 @@
+
+/*
+ *  When calling makeShader, pass
+ *      radius_scale    0.5
+ *      center          x, y
+ *      color0          r, g, b, a
+ *      color1          r, g, b, a
+ */
+function MakeSpiralShaderEffect(CanvasKit) {
+    const _spiralSkSL = `
+    uniform float rad_scale;
+    uniform float2 in_center;
+    uniform float4 in_colors0;
+    uniform float4 in_colors1;
+
+    half4 main(float2 p) {
+        float2 pp = p - in_center;
+        float radius = sqrt(dot(pp, pp));
+        radius = sqrt(radius);
+        float angle = atan(pp.y / pp.x);
+        float t = (angle + 3.1415926/2) / (3.1415926);
+        t += radius * rad_scale;
+        t = fract(t);
+        return half4(mix(in_colors0, in_colors1, t));
+    }`;
+
+    return CanvasKit.RuntimeEffect.Make(_spiralSkSL);
+}
diff --git a/third_party/skia/demos.skia.org/demos/textedit/textapi_utils.js b/third_party/skia/demos.skia.org/demos/textedit/textapi_utils.js
new file mode 100644
index 0000000..9e6df24
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/textedit/textapi_utils.js
@@ -0,0 +1,638 @@
+
+function ASSERT(pred) {
+    console.assert(pred, 'assert failed');
+}
+
+function LOG(...args) {
+    // comment out for non-debugging
+//    console.log(args);
+}
+
+function MakeCursor(CanvasKit) {
+    const linePaint = new CanvasKit.Paint();
+    linePaint.setColor([0,0,1,1]);
+    linePaint.setStyle(CanvasKit.PaintStyle.Stroke);
+    linePaint.setStrokeWidth(2);
+    linePaint.setAntiAlias(true);
+
+    const pathPaint = new CanvasKit.Paint();
+    pathPaint.setColor([0,0,1,0.25]);
+    linePaint.setAntiAlias(true);
+
+    return {
+        _line_paint: linePaint,    // wrap in weak-ref so we can delete it?
+        _path_paint: pathPaint,
+        _x: 0,
+        _top: 0,
+        _bottom: 0,
+        _path: null,            // only use x,top,bottom if path is null
+        _draws_per_sec: 2,
+
+        // pass 0 for no-draw, pass inf. for always on
+        setBlinkRate: function(blinks_per_sec) {
+            this._draws_per_sec = blinks_per_sec;
+        },
+        place: function(x, top, bottom) {
+            this._x = x;
+            this._top = top;
+            this._bottom = bottom;
+
+            this.setPath(null);
+        },
+        setPath: function(path) {
+            if (this._path) {
+                this._path.delete();
+            }
+            this._path = path;
+        },
+        draw_before: function(canvas) {
+            if (this._path) {
+                canvas.drawPath(this._path, this._path_paint);
+            }
+        },
+        draw_after: function(canvas) {
+            if (this._path) {
+                return;
+            }
+            if (Math.floor(Date.now() * this._draws_per_sec / 1000) & 1) {
+                canvas.drawLine(this._x, this._top, this._x, this._bottom, this._line_paint);
+            }
+        },
+    };
+}
+
+function MakeMouse() {
+    return {
+        _start_x: 0, _start_y: 0,
+        _curr_x:  0,  _curr_y: 0,
+        _active: false,
+
+        isActive: function() {
+            return this._active;
+        },
+        setDown: function(x, y) {
+            this._start_x = this._curr_x = x;
+            this._start_y = this._curr_y = y;
+            this._active = true;
+        },
+        setMove: function(x, y) {
+            this._curr_x = x;
+            this._curr_y = y;
+        },
+        setUp: function(x, y) {
+            this._curr_x = x;
+            this._curr_y = y;
+            this._active = false;
+        },
+        getPos: function(dx, dy) {
+            return [ this._start_x + dx, this._start_y + dy, this._curr_x + dx, this._curr_y + dy ];
+        },
+    };
+}
+
+function runs_x_to_index(runs, x) {
+    for (const r of runs) {
+        for (let i = 1; i < r.offsets.length; i += 1) {
+            if (x < r.positions[i*2]) {
+                const mid = (r.positions[i*2-2] + r.positions[i*2]) * 0.5;
+                if (x <= mid) {
+                    return r.offsets[i-1];
+                } else {
+                    return r.offsets[i];
+                }
+            }
+        }
+    }
+    const r = runs[runs.length-1];
+    return r.offsets[r.offsets.length-1];
+}
+
+function lines_pos_to_index(lines, x, y) {
+    if (y < lines[0].top) {
+        return 0;
+    }
+    const l = lines.find((l) => y <= l.bottom);
+    return l ? runs_x_to_index(l.runs, x)
+             : lines[lines.length - 1].textRange.last;
+}
+
+function runs_index_to_run(runs, index) {
+    const r = runs.find((r) => index <= r.offsets[r.offsets.length-1]);
+    // return last if no run is found
+    return r ? r : runs[runs.length-1];
+}
+
+function runs_index_to_x(runs, index) {
+    const r = runs_index_to_run(runs, index);
+    const i = r.offsets.findIndex((offset) => index === offset);
+    return i >= 0 ? r.positions[i*2]
+                  : r.positions[r.positions.length-2]; // last x
+}
+
+function lines_index_to_line_index(lines, index) {
+    const l = lines.findIndex((l) => index <= l.textRange.last);
+    return l >= 0 ? l : lines.length-1;
+}
+
+function lines_index_to_line(lines, index) {
+  return lines[lines_index_to_line_index(lines, index)];
+}
+
+function lines_indices_to_path(lines, a, b, width) {
+    if (a == b) {
+        return null;
+    }
+    if (a > b) { [a, b] = [b, a]; }
+
+    const path = new CanvasKit.Path();
+    const la = lines_index_to_line(lines, a);
+    const lb = lines_index_to_line(lines, b);
+    const ax = runs_index_to_x(la.runs, a);
+    const bx = runs_index_to_x(lb.runs, b);
+    if (la == lb) {
+        path.addRect([ax, la.top, bx, la.bottom]);
+    } else {
+        path.addRect([ax, la.top, width, la.bottom]);
+        path.addRect([0, lb.top, bx, lb.bottom]);
+        if (la.bottom < lb.top) {
+            path.addRect([0, la.bottom, width, lb.top]);   // extra lines inbetween
+        }
+    }
+    return path;
+}
+
+function string_del(str, start, end) {
+    return str.slice(0, start) + str.slice(end, str.length);
+}
+
+function make_default_paint() {
+    const p = new CanvasKit.Paint();
+    p.setAntiAlias(true);
+    return p;
+}
+
+function make_default_font(tf) {
+    const font = new CanvasKit.Font(tf);
+    font.setSubpixel(true);
+    return font;
+}
+
+function MakeStyle(length) {
+    return {
+        _length: length,
+        typeface: null,
+        size: null,
+        color: null,
+        bold: null,
+        italic: null,
+
+        _check_toggle: function(src, dst) {
+            if (src == 'toggle') {
+                return !dst;
+            } else {
+                return src;
+            }
+        },
+
+        // returns true if we changed something affecting layout
+        mergeFrom: function(src) {
+            let layoutChanged = false;
+
+            if (src.typeface && this.typeface !== src.typeface) {
+                this.typeface = src.typeface;
+                layoutChanged = true;
+            }
+            if (src.size && this.size !== src.size) {
+                this.size = src.size;
+                layoutChanged = true;
+            }
+            if (src.color) {
+                this.color = src.color;
+                delete this.shaderIndex;    // we implicitly delete shader if there is a color
+            }
+
+            if (src.bold) {
+                this.bold = this._check_toggle(src.bold, this.bold);
+            }
+            if (src.italic) {
+                this.italic = this._check_toggle(src.italic, this.italic);
+            }
+            if (src.wavy) {
+                this.wavy = this._check_toggle(src.wavy, this.wavy);
+            }
+
+            if (src.size_add) {
+                this.size += src.size_add;
+                layoutChanged = true;
+            }
+
+            if ('shaderIndex' in src) {
+                if (src.shaderIndex >= 0) {
+                    this.shaderIndex = src.shaderIndex;
+                } else {
+                    delete this.shaderIndex;
+                }
+            }
+            return layoutChanged;
+        }
+    };
+}
+
+function MakeEditor(text, style, cursor, width) {
+    const ed = {
+        _text: text,
+        _lines: null,
+        _cursor: cursor,
+        _width: width,
+        _index: { start: 0, end: 0 },
+        _styles: null,
+        // drawing
+        _X: 0,
+        _Y: 0,
+        _paint: make_default_paint(),
+        _font: make_default_font(style.typeface),
+
+        getLines: function() { return this._lines; },
+
+        width: function() {
+            return this._width;
+        },
+        height: function() {
+            return this._lines[this._lines.length-1].bottom;
+        },
+        bounds: function() {
+            return [this._X, this._Y, this._X + this.width(), this._Y + this.height()];
+        },
+        setXY: function(x, y) {
+            this._X = x;
+            this._Y = y;
+        },
+
+        _rebuild_selection: function() {
+            const a = this._index.start;
+            const b = this._index.end;
+            ASSERT(a >= 0 && a <= b && b <= this._text.length);
+            if (a === b) {
+                const l = lines_index_to_line(this._lines, a);
+                const x = runs_index_to_x(l.runs, a);
+                this._cursor.place(x, l.top, l.bottom);
+            } else {
+                this._cursor.setPath(lines_indices_to_path(this._lines, a, b, this._width));
+            }
+        },
+        setIndex: function(i) {
+            this._index.start = this._index.end = i;
+            this._rebuild_selection();
+        },
+        setIndices: function(a, b) {
+            if (a > b) { [a, b] = [b, a]; }
+            this._index.start = a;
+            this._index.end = b;
+            this._rebuild_selection();
+        },
+        moveDX: function(dx) {
+            let index;
+            if (this._index.start == this._index.end) {
+                // just adjust and pin
+                index = Math.max(Math.min(this._index.start + dx, this._text.length), 0);
+            } else {
+                // 'deselect' the region, and turn it into just a single index
+                index = dx < 0 ? this._index.start : this._index.end;
+            }
+            this.setIndex(index);
+        },
+        moveDY: function(dy) {
+            let index = (dy < 0) ? this._index.start : this._index.end;
+            const i = lines_index_to_line_index(this._lines, index);
+            if (dy < 0 && i === 0) {
+                index = 0;
+            } else if (dy > 0 && i == this._lines.length - 1) {
+                index = this._text.length;
+            } else {
+                const x = runs_index_to_x(this._lines[i].runs, index);
+                // todo: statefully track "original" x when an up/down sequence started,
+                //       so we can avoid drift.
+                index = runs_x_to_index(this._lines[i+dy].runs, x);
+            }
+            this.setIndex(index);
+        },
+
+        _validateStyles: function() {
+            const len = this._styles.reduce((sum, style) => sum + style._length, 0);
+            ASSERT(len === this._text.length);
+        },
+        _validateBlocks: function(blocks) {
+            const len = blocks.reduce((sum, block) => sum + block.length, 0);
+            ASSERT(len === this._text.length);
+        },
+
+        _buildLines: function() {
+            this._validateStyles();
+
+            const blocks = [];
+            let block = null;
+            for (const s of this._styles) {
+                if (!block || (block.typeface === s.typeface && block.size === s.size)) {
+                    if (!block) {
+                        block = { length: 0, typeface: s.typeface, size: s.size };
+                    }
+                    block.length += s._length;
+                } else {
+                    blocks.push(block);
+                    block = { length: s._length, typeface: s.typeface, size: s.size };
+                }
+            }
+            blocks.push(block);
+            this._validateBlocks(blocks);
+
+            this._lines = CanvasKit.ParagraphBuilder.ShapeText(this._text, blocks, this._width);
+            this._rebuild_selection();
+
+            // add textRange to each run, to aid in drawing
+            this._runs = [];
+            for (const l of this._lines) {
+                for (const r of l.runs) {
+                    r.textRange = { start: r.offsets[0], end: r.offsets[r.offsets.length-1] };
+                    this._runs.push(r);
+                }
+            }
+        },
+
+        // note: this does not rebuild lines/runs, or update the cursor,
+        //       but it does edit the text and styles
+        // returns true if it deleted anything
+        _deleteRange: function(start, end) {
+            ASSERT(start >= 0 && end <= this._text.length);
+            ASSERT(start <= end);
+            if (start === end) {
+                return false;
+            }
+
+            this._delete_style_range(start, end);
+            // Do this after shrink styles (we use text.length in an assert)
+            this._text = string_del(this._text, start, end);
+        },
+        deleteSelection: function(direction) {
+            let start = this._index.start;
+            if (start == this._index.end) {
+                if (direction < 0) {
+                    if (start == 0) {
+                        return;     // nothing to do
+                    }
+                    this._deleteRange(start - 1, start);
+                    start -= 1;
+                } else {
+                    if (start >= this._text.length) {
+                        return;     // nothing to do
+                    }
+                    this._deleteRange(start, start + 1);
+                }
+            } else {
+                this._deleteRange(start, this._index.end);
+            }
+            this._index.start = this._index.end = start;
+            this._buildLines();
+        },
+        insert: function(charcode) {
+            const len = charcode.length;
+            if (this._index.start != this._index.end) {
+                this.deleteSelection();
+            }
+            const index = this._index.start;
+
+            // do this before edit the text (we use text.length in an assert)
+            const [i, prev_len] = this.find_style_index_and_prev_length(index);
+            this._styles[i]._length += len;
+
+            // now grow the text
+            this._text = this._text.slice(0, index) + charcode + this._text.slice(index);
+
+            this._index.start = this._index.end = index + len;
+            this._buildLines();
+        },
+
+        draw: function(canvas, shaders) {
+            canvas.save();
+            canvas.translate(this._X, this._Y);
+
+            this._cursor.draw_before(canvas);
+
+            const runs = this._runs;
+            const styles = this._styles;
+            const f = this._font;
+            const p = this._paint;
+
+            let s = styles[0];
+            let sindex = 0;
+            let s_start = 0;
+            let s_end = s._length;
+
+            let r = runs[0];
+            let rindex = 0;
+
+            let start = 0;
+            let end = 0;
+            while (start < this._text.length) {
+                while (r.textRange.end <= start) {
+                    r = runs[++rindex];
+                    if (!r) {
+                        // ran out of runs, so the remaining text must just be WS
+                        break;
+                    }
+                }
+                if (!r) break;
+                while (s_end <= start) {
+                    s = styles[++sindex];
+                    s_start = s_end;
+                    s_end += s._length;
+                }
+                end = Math.min(r.textRange.end, s_end);
+
+                LOG('New range: ', start, end,
+                    'from run', r.textRange.start, r.textRange.end,
+                    'style', s_start, s_end);
+
+                // check that we have anything to draw
+                if (r.textRange.start >= end) {
+                    start = end;
+                    continue;  // could be a span of WS with no glyphs
+                }
+
+//              f.setTypeface(r.typeface); // r.typeface is always null (for now)
+                f.setSize(r.size);
+                f.setEmbolden(s.bold);
+                f.setSkewX(s.italic ? -0.2 : 0);
+                p.setColor(s.color ? s.color : [0,0,0,1]);
+                p.setShader(s.shaderIndex >= 0 ? shaders[s.shaderIndex] : null);
+
+                let gly = r.glyphs;
+                let pos = r.positions;
+                if (start > r.textRange.start || end < r.textRange.end) {
+                    // search for the subset of glyphs to draw
+                    let glyph_start, glyph_end;
+                    for (let i = 0; i < r.offsets.length; ++i) {
+                        if (r.offsets[i] >= start) {
+                            glyph_start = i;
+                            break;
+                        }
+                    }
+                    for (let i = glyph_start+1; i < r.offsets.length; ++i) {
+                        if (r.offsets[i] >= end) {
+                            glyph_end = i;
+                            break;
+                        }
+                    }
+                    LOG('    glyph subrange', glyph_start, glyph_end);
+                    gly = gly.slice(glyph_start, glyph_end);
+                    pos = pos.slice(glyph_start*2, glyph_end*2);
+                } else {
+                    LOG('    use entire glyph run');
+                }
+
+                let working_pos = pos;
+                if (s.wavy) {
+                    const xscale = 0.05;
+                    const yscale = r.size * 0.125;
+                    let wavy = [];
+                    for (let i = 0; i < pos.length; i += 2) {
+                        const x = pos[i + 0];
+                        wavy.push(x);
+                        wavy.push(pos[i + 1] + Math.sin(x * xscale) * yscale);
+                    }
+                    working_pos = new Float32Array(wavy);
+                }
+                canvas.drawGlyphs(gly, working_pos, 0, 0, f, p);
+
+                p.setShader(null);  // in case our caller deletes their shader(s)
+
+                start = end;
+            }
+
+            this._cursor.draw_after(canvas);
+            canvas.restore();
+        },
+
+        // Styling
+
+        // returns [index, prev total length before this style]
+        find_style_index_and_prev_length: function(index) {
+            let len = 0;
+            for (let i = 0; i < this._styles.length; ++i) {
+                const l = this._styles[i]._length;
+                len += l;
+                // < favors the latter style if index is between two styles
+                if (index < len) {
+                    return [i, len - l];
+                }
+            }
+            ASSERT(len === this._text.length);
+            return [this._styles.length-1, len];
+        },
+        _delete_style_range: function(start, end) {
+            // shrink/remove styles
+            //
+            // [.....][....][....][.....]  styles
+            //    [..................]     start...end
+            //
+            // - trim the first style
+            // - remove the middle styles
+            // - trim the last style
+
+            let N = end - start;
+            let [i, prev_len] = this.find_style_index_and_prev_length(start);
+            let s = this._styles[i];
+            if (start > prev_len) {
+                // we overlap the first style (but not entirely
+                const skip = start - prev_len;
+                ASSERT(skip < s._length);
+                const shrink = Math.min(N, s._length - skip);
+                ASSERT(shrink > 0);
+                s._length -= shrink;
+                N -= shrink;
+                if (N === 0) {
+                    return;
+                }
+                i += 1;
+                ASSERT(i < this._styles.length);
+            }
+            while (N > 0) {
+                s = this._styles[i];
+                if (N >= s._length) {
+                    N -= s._length;
+                    this._styles.splice(i, 1);
+                } else {
+                    s._length -= N;
+                    break;
+                }
+            }
+        },
+
+        applyStyleToRange: function(style, start, end) {
+            if (start > end) { [start, end] = [end, start]; }
+            ASSERT(start >= 0 && end <= this._text.length);
+            if (start === end) {
+                return;
+            }
+
+            LOG('trying to apply', style, start, end);
+            let i;
+            for (i = 0; i < this._styles.length; ++i) {
+                if (start <= this._styles[i]._length) {
+                    break;
+                }
+                start -= this._styles[i]._length;
+                end -= this._styles[i]._length;
+            }
+
+            let s = this._styles[i];
+            // do we need to fission off a clean subset for the head of s?
+            if (start > 0) {
+                const ns = Object.assign({}, s);
+                s._length = start;
+                ns._length -= start;
+                LOG('initial splice', i, start, s._length, ns._length);
+                i += 1;
+                this._styles.splice(i, 0, ns);
+                end -= start;
+                // we don't use start any more
+            }
+            // merge into any/all whole styles we overlap
+            let layoutChanged = false;
+            while (end >= this._styles[i]._length) {
+                LOG('whole run merging for style index', i)
+                layoutChanged |= this._styles[i].mergeFrom(style);
+                end -= this._styles[i]._length;
+                i += 1;
+                if (end == 0) {
+                    break;
+                }
+            }
+            // do we partially cover the last run
+            if (end > 0) {
+                s = this._styles[i];
+                const ns = Object.assign({}, s);    // the new first half
+                ns._length = end;
+                s._length -= end;                   // trim the (unchanged) tail
+                LOG('merging tail', i, ns._length, s._length);
+                layoutChanged |= ns.mergeFrom(style);
+                this._styles.splice(i, 0, ns);
+            }
+
+            this._validateStyles();
+            LOG('after applying styles', this._styles);
+
+            if (layoutChanged) {
+                this._buildLines();
+            }
+        },
+        applyStyleToSelection: function(style) {
+            this.applyStyleToRange(style, this._index.start, this._index.end);
+        },
+    };
+
+    const s = MakeStyle(ed._text.length);
+    s.mergeFrom(style);
+    ed._styles = [ s ];
+    ed._buildLines();
+    return ed;
+}
diff --git a/third_party/skia/demos.skia.org/demos/textures/index.html b/third_party/skia/demos.skia.org/demos/textures/index.html
new file mode 100644
index 0000000..0a1f9b6
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/textures/index.html
@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<title>Texture demos</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<script type="text/javascript" src="https://particles.skia.org/dist/canvaskit.js"></script>
+
+<style>
+  canvas {
+    border: 1px dashed grey;
+  }
+  #sourceImage, #sourceVideo {
+      width: 100px;
+      height: 100px;
+      border: 1px solid red;
+      cursor: pointer;
+  }
+</style>
+
+<body>
+  <h1>User Defined Textures</h1>
+
+  <p>Tap on one of the texture sources to switch to it.</p>
+
+  <canvas id=draw width=500 height=500></canvas>
+
+  <img id="sourceImage" src="/demos/textures/testimg.png">
+	<video id="sourceVideo" autoplay muted></video>
+</body>
+
+<script type="text/javascript" charset="utf-8">
+  const ckLoaded = CanvasKitInit({ locateFile: (file) => 'https://particles.skia.org/dist/' + file });
+  const canvasEle = document.getElementById('draw');
+  const imgEle = document.getElementById('sourceImage');
+  const videoEle = document.getElementById('sourceVideo');
+
+  // Links the web cam to the video element.
+  navigator.mediaDevices.getUserMedia({ video: true }).then((stream) => {
+    videoEle.srcObject = stream;
+  }).catch((err) => {
+    console.error("Could not set up video", err);
+  });
+
+  // Enables switching between texture sources.
+  let srcEle = imgEle;
+  imgEle.addEventListener('click', () => {
+    srcEle = imgEle;
+  });
+  videoEle.addEventListener('click', () => {
+    srcEle = videoEle;
+  });
+
+  Promise.all([
+    ckLoaded,
+    imgEle.decode(),
+]).then(([
+    CanvasKit,
+    shouldBeNull,
+]) => {
+    const surface = CanvasKit.MakeCanvasSurface('draw');
+    if (!surface) {
+      throw 'Could not make surface';
+    }
+    const paint = new CanvasKit.Paint();
+    // This image will have its underlying texture re-used once per frame.
+    const img = surface.makeImageFromTextureSource(srcEle);
+
+    let lastTS = Date.now();
+    function drawFrame(canvas) {
+      const now = Date.now();
+      canvas.rotate(10 * (now - lastTS) / 1000, 250, 250);
+      lastTS = now;
+      // Re-use the image's underlying texture, but replace the contents of the old texture
+      // with the contents of srcEle
+      surface.updateTextureFromSource(img, srcEle);
+      canvas.clear(CanvasKit.Color(200, 200, 200));
+      canvas.drawImage(img, 5, 5, paint);
+      surface.requestAnimationFrame(drawFrame);
+    }
+    surface.requestAnimationFrame(drawFrame);
+  });
+
+</script>
diff --git a/third_party/skia/demos.skia.org/demos/textures/testimg.png b/third_party/skia/demos.skia.org/demos/textures/testimg.png
new file mode 100644
index 0000000..c2efb81
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/textures/testimg.png
Binary files differ
diff --git a/third_party/skia/demos.skia.org/demos/up_scaling/index.html b/third_party/skia/demos.skia.org/demos/up_scaling/index.html
new file mode 100644
index 0000000..c2f3f7f
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/up_scaling/index.html
@@ -0,0 +1,183 @@
+<!doctype HTML>
+
+<!DOCTYPE html>
+<title>Custom Image Upscaling</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<script type="text/javascript" src="https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/canvaskit.js"></script>
+
+<style>
+canvas {
+  border: 1px dashed grey;
+}
+</style>
+
+<body>
+  <h1>Custom Image Upscaling</h1>
+
+  <div id=scale_text></div>
+  <div class="slidecontainer">
+      <input type="range" min="100" max="500" value="100"   class="slider" id="scale_slider">
+  </div>
+
+  <canvas id=draw width=1000 height=400></canvas>
+</body>
+
+<script type="text/javascript" charset="utf-8">
+let CanvasKit;
+onload = async () => {
+  CanvasKit = await CanvasKitInit({ locateFile: (file) => "https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/" + file });
+  init();
+};
+
+function init() {
+  if (!CanvasKit.RuntimeEffect) {
+      console.log(CanvasKit.RuntimeEffect);
+      throw "Need RuntimeEffect";
+  }
+  const surface = CanvasKit.MakeCanvasSurface('draw');
+  if (!surface) {
+    throw 'Could not make surface';
+  }
+
+  const prog = `
+  uniform shader image;
+  uniform float  sharp;  // slope of the lerp section of the kernel (steeper == sharper)
+
+  float2 sharpen(float2 w) {
+      // we think of sharp as a slope on a shifted line
+      // y = sharp * (w - 0.5) + 0.5
+      // Rewrite with mix needed for some GPUs to be correct
+      return saturate(mix(float2(0.5), w, sharp));
+  }
+
+  bool nearly_center(float2 p) {
+      float tolerance = 1/255.0;
+      p = abs(fract(p) - 0.5);
+      return p.x < tolerance && p.y < tolerance;
+  }
+
+  half4 main(float2 p) {
+      // p+1/2, p-1/2 can be numerically unstable when near the center, so we
+      // detect that case, and just sample at our center.
+      float h = nearly_center(p) ? 0.0 : 0.5;
+
+      // Manual bilerp logic
+      half4 pa = image.eval(float2(p.x-h, p.y-h));
+      half4 pb = image.eval(float2(p.x+h, p.y-h));
+      half4 pc = image.eval(float2(p.x-h, p.y+h));
+      half4 pd = image.eval(float2(p.x+h, p.y+h));
+
+      // Now 'sharpen' the weighting. This is the magic sauce where we different
+      // from a normal bilerp
+      float2 w = sharpen(fract(p + 0.5));
+      return mix(mix(pa, pb, w.x),
+                 mix(pc, pd, w.x), w.y);
+  }
+  `;
+  const effect = CanvasKit.RuntimeEffect.Make(prog);
+
+  const size = 100;
+  const shader_paint = new CanvasKit.Paint();
+  const color_paint = new CanvasKit.Paint();
+
+  const image = function() {
+    let surf = CanvasKit.MakeSurface(size, size);
+    let c = surf.getCanvas();
+
+    color_paint.setColor([1, 1, 1, 1]);
+    c.drawRect([0, 0, size, size], color_paint);
+
+    color_paint.setColor([0, 0, 0, 1]);
+    for (let x = 0; x < size; x += 2) {
+      c.drawRect([x, 0, x+1, size], color_paint);
+    }
+    return surf.makeImageSnapshot();
+  }();
+
+  const imageShader = image.makeShaderOptions(CanvasKit.TileMode.Clamp,
+                                              CanvasKit.TileMode.Clamp,
+                                              CanvasKit.FilterMode.Nearest,
+                                              CanvasKit.MipmapMode.None);
+
+  scale_slider.oninput = () => { surface.requestAnimationFrame(drawFrame); }
+
+  const fract = function(value) {
+      return value - Math.floor(value);
+  }
+
+  // Uses custom sampling (4 sample points per-pixel)
+  draw_one_pass = function(canvas, y, scale) {
+      canvas.save();
+      canvas.scale(scale, 1.0);
+      shader_paint.setShader(effect.makeShaderWithChildren([Math.round(scale)], [imageShader], null));
+      canvas.drawRect([0, 0, size, y], shader_paint);
+      canvas.restore();
+  }
+
+  // First creates an upscaled image, and then bilerps it
+  draw_two_pass = function(canvas, y, scale) {
+      let intScale = Math.max(1, Math.floor(scale + 0.5));
+      let intImage = imageAtScale(intScale);
+
+      canvas.save();
+      canvas.scale(scale / intScale, 1);
+      canvas.drawImageOptions(intImage, 0, y, CanvasKit.FilterMode.Linear, CanvasKit.MipmapMode.None, null);
+      canvas.restore();
+  }
+
+  drawFrame = function(canvas) {
+      const scale = scale_slider.value / 100.0;
+      scale_text.innerText = scale
+
+      canvas.clear();
+
+      draw_one_pass(canvas, 100, scale);
+      drawMagnified(canvas, 0, 100);
+
+      draw_two_pass(canvas, 200, scale);
+      drawMagnified(canvas, 200, 300);
+  }
+
+  function drawMagnified(canvas, sampleY, dstY) {
+    let pixels = canvas.readPixels(
+        0, sampleY,
+        { width: 50,
+          height: 1,
+          colorType: CanvasKit.ColorType.RGBA_8888,
+          alphaType: CanvasKit.AlphaType.Premul,
+          colorSpace: CanvasKit.ColorSpace.DISPLAY_P3
+        }
+    );
+
+    for (let i = 0; i < 50; i++) {
+      let color =
+        [ pixels[i*4 + 0] / 255.0,
+          pixels[i*4 + 1] / 255.0,
+          pixels[i*4 + 2] / 255.0,
+          pixels[i*4 + 3] / 255.0 ];
+      color_paint.setColor(color);
+      canvas.drawRect([i*20, dstY, (i+1)*20, dstY + 100], color_paint);
+    }
+  }
+
+  function imageAtScale(s) {
+      let surf = CanvasKit.MakeSurface(s * size, size);
+      let c = surf.getCanvas();
+
+      color_paint.setColor([1, 1, 1, 1]);
+      c.drawRect([0, 0, s * size, size], color_paint);
+
+      color_paint.setColor([0, 0, 0, 1]);
+      for (let x = 0; x < size; x += 2) {
+        c.drawRect([x * s, 0, (x+1) * s, size], color_paint);
+      }
+      return surf.makeImageSnapshot();
+  }
+
+  surface.requestAnimationFrame(drawFrame);
+}
+
+</script>
+
diff --git a/third_party/skia/demos.skia.org/demos/web_worker/index.html b/third_party/skia/demos.skia.org/demos/web_worker/index.html
new file mode 100644
index 0000000..13a3082
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/web_worker/index.html
@@ -0,0 +1,38 @@
+<!DOCTYPE html>
+<title>CanvasKit Web Worker Demo</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+
+<style>
+  canvas {
+    border: 1px dashed grey;
+  }
+
+  .canvas-container {
+    float: left;
+  }
+</style>
+
+<body>
+  <h1>CanvasKit in a Web Worker demo</h1>
+  <p>NOTE: this demo currently only works in chromium-based browsers, where
+    <a href="https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas#Browser_compatibility">
+      Offscreen Canvas
+    </a>
+    is supported.
+  </p>
+
+  <div class="canvas-container">
+    <h2>Normal Canvas</h2>
+    <canvas id="onscreen-canvas" width=500 height=500></canvas>
+    <button id="busy-button">Make main thread busy</button>
+  </div>
+  <div class="canvas-container">
+    <h2>Web Worker Canvas</h2>
+    <canvas id="offscreen-canvas" width=500 height=500></canvas>
+  </div>
+</body>
+<script type="text/javascript" src="https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/canvaskit.js"></script>
+<script type="text/javascript" src="shared.js"></script>
+<script type="text/javascript" src="main.js"></script>
diff --git a/third_party/skia/demos.skia.org/demos/web_worker/main.js b/third_party/skia/demos.skia.org/demos/web_worker/main.js
new file mode 100644
index 0000000..1c8a875
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/web_worker/main.js
@@ -0,0 +1,34 @@
+// Set up worker and send it a message with the canvas to draw to.
+const offscreenCanvas = document.getElementById('offscreen-canvas').transferControlToOffscreen();
+const worker = new Worker('worker.js');
+worker.postMessage({ offscreenCanvas }, [offscreenCanvas]);
+
+const canvasKitInitPromise =
+    CanvasKitInit({locateFile: (file) => 'https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/'+file});
+const skottieJsonPromise =
+    fetch('https://storage.googleapis.com/skia-cdn/misc/lego_loader.json')
+    .then((response) => response.text());
+
+Promise.all([
+    canvasKitInitPromise,
+    skottieJsonPromise
+]).then(([
+    CanvasKit,
+    jsonStr
+]) => {
+    const onscreenCanvas = document.getElementById('onscreen-canvas');
+    const surface = CanvasKit.MakeWebGLCanvasSurface(onscreenCanvas, null);
+    if (!surface) {
+        throw 'Could not make canvas surface';
+    }
+
+    SkottieExample(CanvasKit, surface, jsonStr);
+});
+
+document.getElementById('busy-button').addEventListener('click', () => {
+    const startTime = performance.now();
+    // This loop runs for 1300ms, emulating computation.
+    // 1300ms was chosen because it causes a visually obvious lag in the lego loader animation.
+    while (performance.now() - startTime < 1300) {
+    }
+});
diff --git a/third_party/skia/demos.skia.org/demos/web_worker/shared.js b/third_party/skia/demos.skia.org/demos/web_worker/shared.js
new file mode 100644
index 0000000..4f212f1
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/web_worker/shared.js
@@ -0,0 +1,20 @@
+function SkottieExample(CanvasKit, surface, jsonStr, bounds) {
+    if (!CanvasKit || !jsonStr) {
+        return;
+    }
+    const animation = CanvasKit.MakeAnimation(jsonStr);
+    const duration = animation.duration() * 1000;
+
+    const firstFrame = performance.now();
+
+    function drawFrame(skcanvas) {
+        const now = performance.now();
+        const seek = ((now - firstFrame) / duration) % 1.0;
+
+        animation.seek(seek);
+        animation.render(skcanvas, [0, 0, 500, 500]);
+
+        surface.requestAnimationFrame(drawFrame);
+    }
+    surface.requestAnimationFrame(drawFrame);
+}
\ No newline at end of file
diff --git a/third_party/skia/demos.skia.org/demos/web_worker/worker.js b/third_party/skia/demos.skia.org/demos/web_worker/worker.js
new file mode 100644
index 0000000..c403654
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/web_worker/worker.js
@@ -0,0 +1,27 @@
+importScripts('https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/canvaskit.js');
+importScripts('shared.js');
+
+const transferCanvasToOffscreenPromise =
+    new Promise((resolve) => addEventListener('message', resolve));
+const canvasKitInitPromise =
+    CanvasKitInit({locateFile: (file) => 'https://unpkg.com/canvaskit-wasm@0.25.0/bin/full/'+file});
+const skottieJsonPromise =
+    fetch('https://storage.googleapis.com/skia-cdn/misc/lego_loader.json')
+    .then((response) => response.text());
+
+Promise.all([
+    transferCanvasToOffscreenPromise,
+    canvasKitInitPromise,
+    skottieJsonPromise
+]).then(([
+    { data: { offscreenCanvas } },
+    CanvasKit,
+    jsonStr
+]) => {
+    const surface = CanvasKit.MakeWebGLCanvasSurface(offscreenCanvas, null);
+    if (!surface) {
+        throw 'Could not make canvas surface';
+    }
+
+    SkottieExample(CanvasKit, surface, jsonStr);
+});
diff --git a/third_party/skia/demos.skia.org/demos/webgpu/index.html b/third_party/skia/demos.skia.org/demos/webgpu/index.html
new file mode 100644
index 0000000..049c30f
--- /dev/null
+++ b/third_party/skia/demos.skia.org/demos/webgpu/index.html
@@ -0,0 +1,144 @@
+<!DOCTYPE html>
+<title>Web GPU Demo</title>
+<meta charset="utf-8" />
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<!-- For *.skia.org https://developer.chrome.com/origintrials/#/registration/2983494015644598273
+     Expires Nov 19, 2021
+     -->
+<meta http-equiv="origin-trial" content="AnRs8mYss+Awd1DPUg2VfjXJbw2087/Dysaa3L7JmrbzTkwoEr87cX3y0zUfTGOFSLKJLRqNEmFAwfy+uumVXQsAAABbeyJvcmlnaW4iOiJodHRwczovL3NraWEub3JnOjQ0MyIsImZlYXR1cmUiOiJXZWJHUFUiLCJleHBpcnkiOjE2NDMxNTUxOTksImlzU3ViZG9tYWluIjp0cnVlfQ==">
+
+<!-- For localhost:8123 https://developer.chrome.com/origintrials/#/registration/6568359319031513089
+     Expires Nov 19, 2021
+     -->
+<meta http-equiv="origin-trial" content="ArQyw1ckz8lMOAcs5BbhOVJh2A6KMhYL6w/rTjPNnViqZyfFhlyJ5hnuHARoCkS1ZKiJi+YbsFvPWy23ePkFMQgAAABJeyJvcmlnaW4iOiJodHRwOi8vbG9jYWxob3N0OjgxMjMiLCJmZWF0dXJlIjoiV2ViR1BVIiwiZXhwaXJ5IjoxNjQzMTU1MTk5fQ==">
+
+<style>
+  canvas {
+    border: 1px dashed grey;
+  }
+</style>
+
+<body>
+  <h1>WebGPU Test</h1>
+  <pre id="log"></pre>
+
+  <canvas id=draw width=500 height=500></canvas>
+</body>
+
+<script type="text/javascript" charset="utf-8">
+  if ("gpu" in navigator) {
+    log("WebGPU detected")
+    WebGPUDemo();
+  } else {
+    log("No WebGPU support.")
+  }
+
+  function log(s) {
+    document.getElementById("log").innerText = s;
+  }
+
+  async function WebGPUDemo() {
+    // Adapted from https://github.com/austinEng/webgpu-samples/blob/main/src/sample/helloTriangle/main.ts
+    const adapter = await navigator.gpu.requestAdapter();
+    if (!adapter) {
+      log("Could not load an adapter. For Chrome, try running with --enable-features=Vulkan --enable-unsafe-webgpu");
+      return;
+    }
+
+    const device = await adapter.requestDevice();
+    console.log(adapter, device);
+    const canvas = document.getElementById("draw");
+    const context = canvas.getContext('webgpu');
+    if (!context) {
+      log("Could not load webgpu context");
+      return;
+    }
+    console.log(context);
+
+    const devicePixelRatio = window.devicePixelRatio || 1;
+    const presentationSize = [
+      canvas.clientWidth * devicePixelRatio,
+      canvas.clientHeight * devicePixelRatio,
+    ];
+    const presentationFormat = context.getPreferredFormat(adapter);
+
+    context.configure({
+      device,
+      format: presentationFormat,
+      size: presentationSize,
+    });
+
+    const triangleVertWGSL = `[[stage(vertex)]]
+fn main([[builtin(vertex_index)]] VertexIndex : u32)
+     -> [[builtin(position)]] vec4<f32> {
+  var pos = array<vec2<f32>, 3>(
+      vec2<f32>(0.0, 0.5),
+      vec2<f32>(-0.5, -0.5),
+      vec2<f32>(0.5, -0.5));
+
+  return vec4<f32>(pos[VertexIndex], 0.0, 1.0);
+}`;
+
+    const redFragWGSL = `[[stage(fragment)]]
+fn main() -> [[location(0)]] vec4<f32> {
+  return vec4<f32>(1.0, 0.0, 0.0, 1.0);
+}`;
+
+    const pipeline = device.createRenderPipeline({
+      vertex: {
+        module: device.createShaderModule({
+          code: triangleVertWGSL,
+        }),
+        entryPoint: 'main',
+      },
+      fragment: {
+        module: device.createShaderModule({
+          code: redFragWGSL,
+        }),
+        entryPoint: 'main',
+        targets: [
+          {
+            format: presentationFormat,
+          },
+        ],
+      },
+      primitive: {
+        topology: 'triangle-list',
+      },
+    });
+
+    console.log(pipeline);
+
+    const startTime = Date.now();
+    function frame() {
+      const now = Date.now();
+      const commandEncoder = device.createCommandEncoder();
+      const textureView = context.getCurrentTexture().createView();
+
+      const renderPassDescriptor = {
+        colorAttachments: [
+          {
+            view: textureView,
+            loadValue: {
+              r: Math.abs(Math.sin((startTime - now) / 500)),
+              g: Math.abs(Math.sin((startTime - now) / 600)),
+              b: Math.abs(Math.sin((startTime - now) / 700)),
+              a: 1.0 },
+            storeOp: 'store',
+          },
+        ],
+      };
+
+      const passEncoder = commandEncoder.beginRenderPass(renderPassDescriptor);
+      passEncoder.setPipeline(pipeline);
+      passEncoder.draw(3, 1, 0, 0);
+      passEncoder.endPass();
+
+      device.queue.submit([commandEncoder.finish()]);
+      requestAnimationFrame(frame);
+    }
+
+    requestAnimationFrame(frame);
+  }
+</script>
\ No newline at end of file