Migrate image-diff plugin to TypeScript

Migrated the image-diff plugin to TypeScript to align with modern
PolyGerrit plugin standards.

- Replaced outdated WCT and HTML tests with TypeScript via @open-wc/testing.
- Migrated code out of root namespace into standard `web/` structure.
- Refactored Vanilla JavaScript LitElement into Lit components using decorators.
- Updated `BUILD` architecture to compile and package through ts_project.
- Cleaned up obsolete npm dependencies.

Bug: b/267985258
Release-Notes: skip
Change-Id: I1503a171bb16aefbfaf4b34f1d68cef3ce1de571
diff --git a/BUILD b/BUILD
index 1dbb10a..067f856 100644
--- a/BUILD
+++ b/BUILD
@@ -1,26 +1,6 @@
 load("//tools/bzl:js.bzl", "polygerrit_plugin")
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
-
 
 polygerrit_plugin(
     name = "image-diff",
-    app = "plugin-bundle.js",
-)
-
-rollup_bundle(
-    name = "plugin-bundle",
-    srcs = glob([
-        "**/*.js",
-    ]) + ["@plugins_npm//:node_modules"],
-    args = [
-        "--bundleConfigAsCjs=true",
-    ],
-    config_file = "rollup.config.js",
-    entry_point = "plugin.js",
-    format = "iife",
-    rollup_bin = "//tools/node_tools:rollup-bin",
-    sourcemap = "hidden",
-    deps = [
-        "@tools_npm//@rollup/plugin-node-resolve",
-    ],
+    app = "//plugins/image-diff/web:image_diff.js",
 )
diff --git a/gr-image-diff-tool/gr-image-diff-tool.js b/gr-image-diff-tool/gr-image-diff-tool.js
deleted file mode 100644
index e5593fe..0000000
--- a/gr-image-diff-tool/gr-image-diff-tool.js
+++ /dev/null
@@ -1,166 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {html, css, LitElement} from 'lit';
-import '../gr-opacity-diff-mode/gr-opacity-diff-mode.js';
-import '../gr-resemble-diff-mode/gr-resemble-diff-mode.js';
-
-const DiffModes = {
-  OPACITY: 'opacity',
-  RESEMBLE: 'resemble',
-};
-
-class ImageDiffTool extends LitElement {
-  static get is() {
-    return 'gr-image-diff-tool';
-  }
-
-  static get styles() {
-    return [
-      css`
-        :host {
-          background-color: var(--table-header-background-color, #fafafa);
-          display: block;
-          font-family: var(--font-family);
-        }
-        #header {
-          align-items: center;
-          border-bottom: 1px solid var(--border-color, #ddd);
-          border-top: 1px solid var(--border-color, #ddd);
-          display: inline-flex;
-          padding: .5em;
-          width: 100%;
-        }
-        h3 {
-          padding: 0 .5em;
-        }
-        #dropdown {
-          background-color: var(--view-background-color);
-          border: 1px solid var(--border-color);
-          border-radius: 2px;
-          color: var(--primary-text-color);
-          font-size: var(--font-size-normal);
-          height: 2em;
-          margin-left: 1em;
-          padding: 0 .15em;
-        }
-        .diffmode {
-          align-items: center;
-          display: flex;
-          justify-content: center;
-        }
-      `
-    ];
-  }
-
-  render() {
-    return html`
-      <div id="header">
-        <h3>Image diff</h3>
-        <select .value=${this._observeMode} @change=${this._handleSelectChange} id="dropdown">
-          <option value="resemble" title="Scale the images to the same size and compute a diff with highlights">Highlight differences</option>
-          <option value="opacity" title="Overlay the new image over the old and use an opacity control to view the differences">Onion skin</option>
-        </select>
-      </div>
-      <div class="diffmode">
-        ${this._showResembleMode ? html`
-          <gr-resemble-diff-mode
-              .baseImage=${this.baseImage}
-              .revisionImage=${this.revisionImage}></gr-resemble-diff-mode>
-        ` : ''}
-      </div>
-      <div class="diffmode">
-        ${this._showOpacityMode ? html`
-          <gr-opacity-diff-mode
-              .baseImage=${this.baseImage}
-              .revisionImage=${this.revisionImage}></gr-opacity-diff-mode>
-        ` : ''}
-      </div>
-    `;
-  }
-
-  static get properties() {
-    return {
-      baseImage: {type: Object},
-      revisionImage: {type: Object},
-      hidden: {type: Boolean, reflect: true},
-      _showResembleMode: {type: Boolean},
-      _showOpacityMode: {type: Boolean},
-      _observeMode: {type: String},
-    };
-  }
-
-  constructor() {
-    super();
-    this.hidden = false;
-    this._showResembleMode = false;
-    this._showOpacityMode = false;
-    this._observeMode = '';
-  }
-
-  updated(changedProperties) {
-    if (changedProperties.has('_observeMode')) {
-      this._handleSelect(this._observeMode);
-    }
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    if (!this.baseImage || !this.revisionImage) {
-      // No need to show the diff tool if there are no images.
-      this.hidden = true;
-    }
-    const diff_mode = this._getMode();
-    diff_mode === DiffModes.OPACITY
-      ? this._displayOpacityMode()
-      : this._displayResembleMode();
-  }
-
-  _getMode() {
-    return window.localStorage.getItem('image-diff-mode');
-  }
-
-  _setMode(mode) {
-    window.localStorage.setItem('image-diff-mode', mode);
-  }
-
-  _handleSelectChange(e) {
-    this._observeMode = e.target.value;
-  }
-
-  _handleSelect(mode) {
-    mode === DiffModes.OPACITY
-      ? this._displayOpacityMode()
-      : this._displayResembleMode();
-  }
-
-  _displayResembleMode() {
-    this._observeMode = DiffModes.RESEMBLE;
-    this._showResembleMode = true;
-    this._showOpacityMode = false;
-    this._setMode(DiffModes.RESEMBLE);
-  }
-
-  _displayOpacityMode() {
-    this._observeMode = DiffModes.OPACITY;
-    this._showResembleMode = false;
-    this._showOpacityMode = true;
-    this._setMode(DiffModes.OPACITY);
-  }
-}
-
-customElements.define(ImageDiffTool.is, ImageDiffTool);
\ No newline at end of file
diff --git a/gr-image-diff-tool/gr-image-diff-tool_test.html b/gr-image-diff-tool/gr-image-diff-tool_test.html
deleted file mode 100644
index 06d86a1..0000000
--- a/gr-image-diff-tool/gr-image-diff-tool_test.html
+++ /dev/null
@@ -1,79 +0,0 @@
-<!--
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>image-diff-tool</title>
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
-<script src="../node_modules/resemblejs/resemble.js"></script>
-
-<test-fixture id="basicFixture">
-  <template>
-    <gr-image-diff-tool>
-    </gr-image-diff-tool>
-  </template>
-</test-fixture>
-
-<script type="module">
-  import '../test/common-test-setup.js';
-  import "./gr-image-diff-tool.js";
-  suite('gr-image-diff-tool tests', async () => {
-    let element;
-    let sandbox;
-
-    setup(async () => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basicFixture');
-      await element.updateComplete;
-    });
-
-    teardown(async () => { sandbox.restore(); });
-
-    test('test opacity mode', async () => {
-      element._observeMode = 'opacity';
-      assert.isTrue(element._showOpacityMode);
-      assert.isFalse(element._showResembleMode);
-      await element.updateComplete;
-
-      assert.ok(element.shadowRoot.querySelector('gr-opacity-diff-mode'));
-      assert.equal(element.shadowRoot.querySelector('gr-resemble-diff-mode'), null);
-    });
-
-    test('test resemble mode', async () => {
-      element._observeMode = 'resemble';
-      assert.isTrue(element._showResembleMode);
-      assert.isFalse(element._showOpacityMode);
-      await element.updateComplete;
-
-      assert.ok(element.shadowRoot.querySelector('gr-resemble-diff-mode'));
-      assert.equal(element.shadowRoot.querySelector('gr-opacity-diff-mode'), null);
-    });
-
-    test('localStorage persists', async () => {
-      sandbox.stub(element, '_setMode');
-      sandbox.stub(element, '_getMode');
-      element.connectedCallback();
-      assert.equal(element._getMode.callCount, 1);
-      assert.equal(element._setMode.callCount, 1);
-      element._observeMode = 'opacity';
-      assert.equal(element._setMode.callCount, 2);
-      element._observeMode = 'resemble';
-      assert.equal(element._setMode.callCount, 3);
-    });
-  });
-</script>
diff --git a/gr-opacity-diff-mode/gr-opacity-diff-mode.js b/gr-opacity-diff-mode/gr-opacity-diff-mode.js
deleted file mode 100644
index 96eb7f2..0000000
--- a/gr-opacity-diff-mode/gr-opacity-diff-mode.js
+++ /dev/null
@@ -1,181 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {html, css, LitElement} from 'lit';
-
-class OpacityDiffMode extends LitElement {
-  static get is() {
-    return 'gr-opacity-diff-mode';
-  }
-
-  static get styles() {
-    return [
-      css`
-        :host {
-          display: block;
-        }
-        .wrapper {
-          box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-          margin: 1em 0;
-        }
-        img {
-          display: block;
-          height: var(--img-height);
-          margin: auto;
-          position: absolute;
-          width: var(--img-width);
-        }
-        #imageRevision {
-          opacity: var(--my-opacity-value);
-          z-index: 0.5;
-        }
-        #imageDiffContainer {
-          height: var(--div-height);
-          margin: auto;
-          width: var(--div-width);
-        }
-        #controlsContainer {
-          border-top: 1px solid var(--border-color, #ddd);
-          display: flex;
-        }
-        #controlsBox {
-          display: flex;
-          justify-content: space-between;
-          margin: 0 .5em;
-          min-width: 32em;
-          width: 100%;
-        }
-        label {
-          align-items: center;
-          display: flex;
-          padding: 1em .5em;
-        }
-        input {
-          margin: .5em;
-        }
-        #opacitySlider {
-          width: 10em;
-        }
-      `
-    ];
-  }
-
-  render() {
-    return html`
-      <div class="wrapper" style="
-          --my-opacity-value: ${this.opacityValue};
-          --img-width: ${this._scaledWidth ? this._scaledWidth + 'px' : 'initial'};
-          --img-height: ${this._scaledHeight ? this._scaledHeight + 'px' : 'initial'};
-          --div-width: ${this._maxWidth ? this._maxWidth + 'px' : 'initial'};
-          --div-height: ${this._maxHeight ? this._maxHeight + 'px' : 'initial'};
-      ">
-        <div id="imageDiffContainer">
-          <img
-              @load=${this._onImageLoad}
-              id="imageBase"
-              src=${this.computeSrcString(this.baseImage)} />
-          <img
-              @load=${this._onImageLoad}
-              data-opacity=${this.opacityValue}
-              id="imageRevision"
-              src=${this.computeSrcString(this.revisionImage)} />
-        </div>
-        <div id="controlsContainer">
-          <div id="controlsBox">
-            <label>
-              <input
-                  id="scaleSizesToggle"
-                  @click=${this.handleScaleSizesToggle}
-                  type="checkbox">
-              Scale to same size
-            </label>
-            <label>
-              Revision image opacity
-              <input
-                  id="opacitySlider"
-                  max="1.0"
-                  min="0.0"
-                  @input=${this.handleOpacityChange}
-                  step=".01"
-                  type="range"
-                  .value=${this.opacityValue || '0.5'}/>
-            </label>
-          </div>
-        </div>
-      </div>
-    `;
-  }
-
-  static get properties() {
-    return {
-      baseImage: {type: Object},
-      revisionImage: {type: Object},
-      opacityValue: {type: Number},
-      _maxHeight: {type: Number},
-      _maxWidth: {type: Number},
-      _scaledWidth: {type: Number},
-      _scaledHeight: {type: Number},
-    };
-  }
-
-  constructor() {
-    super();
-    this._maxHeight = 0;
-    this._maxWidth = 0;
-    this.opacityValue = 0.5;
-  }
-
-  updated(changedProperties) {
-    if (changedProperties.has('baseImage') || changedProperties.has('revisionImage')) {
-      this._handleImageChange();
-    }
-  }
-
-  _onImageLoad(e) {
-    const target = e.target;
-    this._maxHeight = Math.max(this._maxHeight, target.naturalHeight);
-    this._maxWidth = Math.max(this._maxWidth, target.naturalWidth);
-  }
-
-  _handleImageChange() {
-    if (this.baseImage === undefined || this.revisionImage === undefined) return;
-    this.handleOpacityChange();
-  }
-
-  handleOpacityChange(e) {
-    const value = e ? e.target.value : this.opacityValue;
-    this.opacityValue = Number(value);
-  }
-
-  computeSrcString(image) {
-    if (!image) return '';
-    return 'data:' + image['type'] + ';base64, ' + image['body'];
-  }
-
-  handleScaleSizesToggle(e) {
-    const isChecked = e ? e.target.checked : false;
-    if (isChecked) {
-      this._scaledWidth = this._maxWidth;
-      this._scaledHeight = this._maxHeight;
-    } else {
-      this._scaledWidth = null;
-      this._scaledHeight = null;
-    }
-  }
-}
-
-customElements.define(OpacityDiffMode.is, OpacityDiffMode);
diff --git a/gr-opacity-diff-mode/gr-opacity-diff-mode_test.html b/gr-opacity-diff-mode/gr-opacity-diff-mode_test.html
deleted file mode 100644
index d1fc5ed..0000000
--- a/gr-opacity-diff-mode/gr-opacity-diff-mode_test.html
+++ /dev/null
@@ -1,96 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-http://www.apache.org/licenses/LICENSE-2.0
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>gr-opacity-diff-mode</title>
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
-
-<test-fixture id='opacity-diff-fixture'>
-  <template>
-    <gr-opacity-diff-mode></gr-opacity-diff-mode>
-  </template>
-</test-fixture>
-
-<script type="module">
-  import '../test/common-test-setup.js';
-  import "./gr-opacity-diff-mode.js";
-  function getComputedStyleValue(name, el) {
-    let style;
-    if (window.ShadyCSS) {
-      style = ShadyCSS.getComputedStyleValue(el, name);
-    } else if (el.getComputedStyleValue) {
-      style = el.getComputedStyleValue(name);
-    } else {
-      style = getComputedStyle(el).getPropertyValue(name);
-    }
-    return style;
-  };
-
-  suite('gr-opacity-diff-tool tests', () => {
-    let element;
-
-    setup(() => {
-      element= fixture('opacity-diff-fixture');
-    });
-
-    test('slider changes opacity of image', () => {
-      const arrayOfNumbers = [0.73, 0.5, 0.0];
-      arrayOfNumbers.forEach(number => {
-        assert.notEqual(
-          getComputedStyleValue('--my-opacity-value', element),
-          number);
-        element.$.opacitySlider.value = number;
-        element.handleOpacityChange();
-        assert.equal(
-          getComputedStyleValue('--my-opacity-value', element),
-          number);
-      });
-    });
-
-    test('create the src string for images', () => {
-      element.revisionImage = {
-        type: 'IMG/src',
-        body: 'Zx3Cgffk=',
-      };
-      const expectedOutcome = 'data:IMG/src;base64, Zx3Cgffk=';
-      assert.equal(element.computeSrcString(element.revisionImage),
-          expectedOutcome);
-    });
-
-    test('size scaling', () => {
-      element._maxWidth = 200;
-      element._maxHeight = 400;
-
-      assert.equal(getComputedStyleValue('--img-width', element), '');
-      assert.equal(getComputedStyleValue('--img-height', element), '');
-      MockInteractions.tap(element.$.scaleSizesToggle);
-      flushAsynchronousOperations();
-
-      assert.equal(getComputedStyleValue('--img-width', element), '200px');
-      assert.equal(getComputedStyleValue('--img-height', element), '400px');
-    });
-
-    test('resize the div container for base & revision image comparison', () => {
-      assert.equal(getComputedStyleValue('--div-height', element), '');
-      element._maxHeight = 500;
-      element._maxWidth = 300;
-      flushAsynchronousOperations();
-
-      assert.equal(getComputedStyleValue('--div-height', element), '500px');
-    });
-  });
-</script>
diff --git a/gr-resemble-diff-mode/gr-resemble-diff-mode.js b/gr-resemble-diff-mode/gr-resemble-diff-mode.js
deleted file mode 100644
index f0c895f..0000000
--- a/gr-resemble-diff-mode/gr-resemble-diff-mode.js
+++ /dev/null
@@ -1,275 +0,0 @@
-/**
- * @license
- * Copyright (C) 2018 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {html, css, LitElement} from 'lit';
-
-const DEFAULT_SETTING = {
-  errorType: 'flat',
-  largeImageThreshold: 1200,
-};
-
-class ResembleDiffMode extends LitElement {
-  static get is() {
-    return 'gr-resemble-diff-mode';
-  }
-
-  static get styles() {
-    return [
-      css`
-        :host {
-          display: block;
-        }
-        h2 {
-          display: none;
-        }
-        :host([loading]) #imageDiff {
-          display: none;
-        }
-        :host([loading]) h2 {
-          display: inline;
-          padding: 1em 0;
-        }
-        .toggle {
-          padding: .5em;
-        }
-        #controlsContainer {
-          align-items: center;
-          border-top: 1px solid var(--border-color, #ddd);
-          display: flex;
-          justify-content: space-between;
-          padding: 1em;
-          white-space: nowrap;
-          width: 100%;
-        }
-        #diffContainer {
-          display: block;
-          width: 100%;
-        }
-        #color {
-          border: 1px solid var(--border-color, #ddd);
-          border-radius: 2px;
-        }
-        #fullscreen {
-          border: 1px solid var(--border-color, #ddd);
-          border-radius: 2px;
-          color: var(--primary-text-color, #000);
-          padding: .5em;
-        }
-        #controlsContainer,
-        #color,
-        #fullscreen {
-          background-color: var(--table-header-background-color, #fafafa);
-        }
-        #color:hover,
-        #fullscreen:hover {
-          background-color: var(--header-background-color, #eeeeee);
-        }
-        #imageDiff {
-          display: block;
-          height: auto;
-          margin: auto;
-          max-width: 50em;
-        }
-        #modeContainer {
-          box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-          display: block;
-          margin: 1em 0em;
-          width: 50em;
-        }
-      `
-    ];
-  }
-
-  render() {
-    return html`
-      <div id="modeContainer">
-        <div id="diffContainer">
-          <h2>Loading...</h2>
-          <img id="imageDiff" src=${this._diffImageSrc || ''}>
-        </div>
-        <div id="controlsContainer">
-          <div>${this._difference}% changed</div>
-          <label class="toggle">
-            <input
-                id="ignoreColorsToggle"
-                type="checkbox"
-                .checked=${this._ignoreColors}
-                @click=${this._handleIgnoreColorsToggle}>
-            Ignore colors
-          </label>
-          <label class="toggle">
-            <input
-                id="transparentToggle"
-                type="checkbox"
-                .checked=${this._transparent}
-                @click=${this._handleTransparentToggle}>
-            Transparent
-          </label>
-          <input
-              id="color"
-              type="color"
-              .value=${this._colorValue}
-              @change=${this._handleColorChange}>
-          <button
-              id="fullscreen"
-              @click=${this._handleFullScreen}>
-            View full sized
-          </button>
-        </div>
-      </div>
-    `;
-  }
-
-  static get properties() {
-    return {
-      baseImage: {type: Object},
-      revisionImage: {type: Object},
-      _colorValue: {type: String},
-      _difference: {type: Number},
-      _ignoreColors: {type: Boolean},
-      _transparent: {type: Boolean},
-      _diffImageSrc: {type: String},
-      loading: {type: Boolean, reflect: true},
-    };
-  }
-
-  constructor() {
-    super();
-    this._colorValue = '#00ffff';
-    this._difference = 0;
-    this._ignoreColors = false;
-    this._transparent = false;
-    this._diffImageSrc = '';
-    this.loading = false;
-  }
-
-  updated(changedProperties) {
-    if (changedProperties.has('baseImage') || changedProperties.has('revisionImage')) {
-      this._handleImageDiff(this.baseImage, this.revisionImage);
-    }
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-    window.resemble.outputSettings(DEFAULT_SETTING);
-  }
-
-  _handleImageDiff(baseImage, revisionImage) {
-    if ([baseImage, revisionImage].includes(undefined)) {
-      return;
-    }
-    this.reload();
-  }
-
-  _setImageDiffSrc(src) {
-    this._diffImageSrc = src;
-  }
-
-  _setDifferenceValue(percentage) {
-    this._difference = percentage;
-  }
-
-  _getDataUrl(image) {
-    return 'data:' + image['type'] + ';base64,' + image['body'];
-  }
-
-  _maybeIgnoreColors(diffProcess, ignoreColors) {
-    ignoreColors ? diffProcess.ignoreColors() : diffProcess.ignoreNothing();
-    return diffProcess;
-  }
-
-  _createDiffProcess(base, rev, ignoreColors) {
-    window.resemble.outputSettings(this._setOutputSetting());
-    const process = window.resemble(base).compareTo(rev);
-    return this._maybeIgnoreColors(process, ignoreColors);
-  }
-
-  _setOutputSetting() {
-    const rgb = this._hexToRGB(this._colorValue);
-    return {
-      transparency: this._transparent ? 0.1 : 1,
-      errorColor: {
-        red: rgb.r,
-        green: rgb.g,
-        blue: rgb.b,
-      },
-    };
-  }
-
-  /**
-   * Reloads the diff. Resemble 1.2.1 seems to have an issue with successive
-   * reloads via the repaint() function, so this implementation creates a
-   * fresh diff each time it is called.
-   *
-   * @return {Promise} resolves if and when the reload succeeds.
-   */
-  reload() {
-    this.loading = true;
-    if (this.baseImage && this.revisionImage) {
-      const base = this._getDataUrl(this.baseImage);
-      const rev = this._getDataUrl(this.revisionImage);
-
-      return new Promise((resolve, reject) => {
-        this._createDiffProcess(base, rev, this._ignoreColors).onComplete(
-            data => {
-              this._setImageDiffSrc(data.getImageDataUrl());
-              this._setDifferenceValue(data.misMatchPercentage);
-              this.loading = false;
-              resolve();
-            }
-        );
-      });
-    }
-    this.loading = false;
-  }
-
-  _handleIgnoreColorsToggle(e) {
-    this._ignoreColors = e.target.checked;
-    this.reload();
-  }
-
-  _handleTransparentToggle(e) {
-    this._transparent = e.target.checked;
-    this.reload();
-  }
-
-  _handleColorChange(e) {
-    this._colorValue = e.target.value;
-    this.reload();
-  }
-
-  _hexToRGB(hex) {
-    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
-    return result
-      ? {
-        r: parseInt(result[1], 16),
-        g: parseInt(result[2], 16),
-        b: parseInt(result[3], 16),
-      }
-      : null;
-  }
-
-  _handleFullScreen() {
-    const w = window.open('about:blank', '_blank');
-    const imageDiff = this.shadowRoot.getElementById('imageDiff');
-    if (imageDiff) {
-      w.document.body.appendChild(imageDiff.cloneNode(true));
-    }
-  }
-}
-
-customElements.define(ResembleDiffMode.is, ResembleDiffMode);
\ No newline at end of file
diff --git a/gr-resemble-diff-mode/gr-resemble-diff-mode_test.html b/gr-resemble-diff-mode/gr-resemble-diff-mode_test.html
deleted file mode 100644
index 18cf465..0000000
--- a/gr-resemble-diff-mode/gr-resemble-diff-mode_test.html
+++ /dev/null
@@ -1,156 +0,0 @@
-<!--
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<meta charset="utf-8">
-<title>resemble-diff-mode</title>
-<script src="/node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js"></script>
-<script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
-<script src="../node_modules/resemblejs/resemble.js"></script>
-
-<test-fixture id="basicFixture">
-  <template>
-    <gr-resemble-diff-mode>
-    </gr-resemble-diff-mode>
-  </template>
-</test-fixture>
-
-<script type="module">
-  import '../test/common-test-setup.js';
-  import "./gr-resemble-diff-mode.js";
-  suite('gr-resemble-diff-mode tests', () => {
-    let element;
-    let sandbox;
-
-    setup(() => {
-      sandbox = sinon.sandbox.create();
-      element = fixture('basicFixture');
-    });
-
-    teardown(() => { sandbox.restore(); });
-
-    test('test initialization', () => {
-      assert.notEqual(element, null);
-      assert.equal(element.baseImage, null);
-      assert.equal(element.revisionImage, null);
-    });
-
-    test('_setImageDiffSrc', () => {
-      const img = element.$.imageDiff;
-      const src = 'data:image/png;base64,SGVsbG8=';
-      assert.notOk(img.getAttribute('src'));
-      element._setImageDiffSrc(src);
-      assert.equal(img.getAttribute('src'), src);
-    });
-
-    test('_setDifferenceValue', () => {
-      const expected = 0.15;
-      element._setDifferenceValue(expected);
-      assert.equal(element._difference, expected);
-    });
-
-    test('_maybeIgnoreColors', () => {
-      const dummyProcess = {
-        ignoreColors: sandbox.stub(),
-        ignoreNothing: sandbox.stub(),
-      };
-      element._maybeIgnoreColors(dummyProcess, false);
-      assert.isFalse(dummyProcess.ignoreColors.called);
-      assert.isTrue(dummyProcess.ignoreNothing.called);
-      element._maybeIgnoreColors(dummyProcess, true);
-      assert.isTrue(dummyProcess.ignoreColors.called);
-    });
-
-    test('_setOutputSetting', () => {
-      sandbox.stub(window.resemble, 'outputSettings');
-      element._transparent = true;
-      element._colorValue = '#00ffff';
-      const expectedResult = {
-        transparency: 0.1,
-        errorColor: {
-          red: 0,
-          green: 255,
-          blue: 255,
-        },
-      };
-      element._createDiffProcess();
-      flushAsynchronousOperations();
-
-      assert.isTrue(window.resemble.outputSettings.called);
-      sinon.assert.calledWith(window.resemble.outputSettings, expectedResult);
-    });
-
-    test('_handleIgnoreColorsToggle', () => {
-      sandbox.stub(element, 'reload');
-      element._ignoreColors = false;
-      assert.isFalse(element.$.ignoreColorsToggle.checked);
-      MockInteractions.tap(element.$.ignoreColorsToggle);
-      flushAsynchronousOperations();
-
-      assert.isTrue(element._ignoreColors);
-      assert.isTrue(element.reload.called);
-    });
-
-    test('_handleTransparentToggle', () => {
-      sandbox.stub(element, 'reload');
-      element._transparent = false;
-      assert.isFalse(element.$.transparentToggle.checked);
-      MockInteractions.tap(element.$.transparentToggle);
-      flushAsynchronousOperations();
-
-      assert.isTrue(element._transparent);
-      assert.isTrue(element.reload.called);
-    });
-
-    test('_handleColorChange', () => {
-      sandbox.stub(element, 'reload');
-      element._colorValue = '';
-      flushAsynchronousOperations();
-
-      assert.isTrue(element.reload.called);
-    });
-
-    test('_hexToRGB', () => {
-      const expected = { r: 0, g: 255, b: 255 };
-      const input = '#00ffff';
-      const output = element._hexToRGB(input);
-      assert.equal(output.r, expected.r);
-      assert.equal(output.g, expected.g);
-      assert.equal(output.b, expected.b);
-    });
-
-    test('_handleFullScreen', () => {
-      sandbox.stub(element, '_handleFullScreen');
-      MockInteractions.tap(element.$.fullscreen);
-      flushAsynchronousOperations();
-
-      assert.isTrue(element._handleFullScreen.called);
-    });
-
-    test('calls reload', () => {
-      sandbox.stub(element, 'reload');
-      element.baseImage = {};
-      assert.equal(element.reload.callCount, 0);
-      element.revisionImage = {};
-      assert.equal(element.reload.callCount, 1);
-      MockInteractions.tap(element.$.ignoreColorsToggle);
-      assert.equal(element.reload.callCount, 2);
-      MockInteractions.tap(element.$.transparentToggle);
-      assert.equal(element.reload.callCount, 3);
-    });
-  });
-</script>
\ No newline at end of file
diff --git a/package.json b/package.json
index 07056cd..1cf90d6 100644
--- a/package.json
+++ b/package.json
@@ -1,26 +1,14 @@
 {
-    "name": "image-diff",
-    "browser": true,
-    "scripts": {
-        "test": "polymer serve",
-        "eslint": "npx eslint --ext .js ./",
-        "eslintfix": "npm run eslint -- --fix",
-        "wct-test": "npm i && npx bower i && npx polymer test -l chrome"
-    },
-    "dependencies": {
-        "resemblejs": "^5.0.0"
-    },
-    "devDependencies": {
-        "@polymer/iron-test-helpers": "^3.0.1",
-        "@webcomponents/webcomponentsjs": "^1.3.3",
-        "bower": "^1.8.8",
-        "es6-promise": "^3.3.1",
-        "eslint": "^6.6.0",
-        "eslint-config-google": "^0.13.0",
-        "eslint-plugin-html": "^6.0.0",
-        "eslint-plugin-import": "^2.20.1",
-        "eslint-plugin-jsdoc": "^19.2.0"
-    },
-    "license": "Apache-2.0",
-    "private": true
+  "name": "image-diff",
+  "dependencies": {
+    "@gerritcodereview/typescript-api": "3.13.0",
+    "lit": "^3.3.1",
+    "resemblejs": "^5.0.0"
+  },
+  "devDependencies": {
+    "@open-wc/testing": "^4.0.0",
+    "sinon": "^20.0.0"
+  },
+  "license": "Apache-2.0",
+  "private": true
 }
diff --git a/test/common-test-setup.js b/test/common-test-setup.js
deleted file mode 100644
index f460703..0000000
--- a/test/common-test-setup.js
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * @license
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-/* eslint-disable */
-import 'polymer-bridges/polymer/lib/utils/boot_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/resolve-url_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/settings_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-module_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/style-gather_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/path_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/case-map_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/async_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/wrap_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/properties-changed_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/property-accessors_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/template-stamp_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/property-effects_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/telemetry_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/properties-mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/debounce_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/gestures_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/gesture-event-listeners_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/dir-mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/render-status_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/unresolved_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/array-splice_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/flattened-nodes-observer_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/flush_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/polymer.dom_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/legacy-element-mixin_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/class_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/polymer-fn_bridge.js';
-import 'polymer-bridges/polymer/lib/mixins/mutable-data_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/templatize_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/templatizer-behavior_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-bind_bridge.js';
-import 'polymer-bridges/polymer/lib/utils/html-tag_bridge.js';
-import 'polymer-bridges/polymer/polymer-element_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-repeat_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/dom-if_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/array-selector_bridge.js';
-import 'polymer-bridges/polymer/lib/elements/custom-style_bridge.js';
-import 'polymer-bridges/polymer/lib/legacy/mutable-data-behavior_bridge.js';
-import 'polymer-bridges/polymer/polymer-legacy_bridge.js';
-
-import '@polymer/iron-test-helpers/iron-test-helpers.js';
diff --git a/test/index.html b/test/index.html
deleted file mode 100644
index 7f5487d..0000000
--- a/test/index.html
+++ /dev/null
@@ -1,34 +0,0 @@
-<!DOCTYPE html>
-<!--
-@license
-Copyright (C) 2018 The Android Open Source Project
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
--->
-
-<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>Test Runner</title>
-<meta charset="utf-8">
-<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
-<script src="../bower_components/web-component-tester/browser.js"></script>
-
-<script>
-  const suites = [
-    '../gr-image-diff-tool/gr-image-diff-tool_test.html',
-    '../gr-opacity-diff-mode/gr-opacity-diff-mode_test.html',
-    '../gr-resemble-diff-mode/gr-resemble-diff-mode_test.html',
-  ];
-
-  WCT.loadSuites(suites);
-  WCT.loadSuites(suites.map(suite => `${suite}?dom=shadow`));
-</script>
\ No newline at end of file
diff --git a/web/BUILD b/web/BUILD
new file mode 100644
index 0000000..01494fc
--- /dev/null
+++ b/web/BUILD
@@ -0,0 +1,67 @@
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+load("//tools/bzl:js.bzl", "gerrit_js_bundle", "web_test_runner")
+load("//tools/js:eslint.bzl", "plugin_eslint")
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/image-diff/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "image-diff-ts",
+    srcs = glob(
+        ["**/*.ts"],
+        exclude = ["**/*test*"],
+    ),
+    incremental = True,
+    out_dir = "_bazel_ts_out",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    deps = [
+        "@plugins_npm//@gerritcodereview/typescript-api",
+        "@plugins_npm//lit",
+    ],
+)
+
+ts_project(
+    name = "image-diff-ts-tests",
+    srcs = glob(["**/*.ts"]),
+    incremental = True,
+    out_dir = "_bazel_ts_out_tests",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":tsconfig",
+    visibility = ["//visibility:public"],
+    deps = [
+        "@plugins_npm//:node_modules",
+        "@ui_dev_npm//:node_modules",
+    ],
+)
+
+gerrit_js_bundle(
+    name = "image_diff",
+    srcs = [":image-diff-ts"],
+    entry_point = "_bazel_ts_out/plugin.js",
+)
+
+web_test_runner(
+    name = "karma_test",
+    srcs = ["karma_test.sh"],
+    data = [
+        ":image-diff-ts-tests",
+        ":tsconfig",
+        "@plugins_npm//:node_modules",
+        "@ui_dev_npm//:node_modules",
+    ],
+)
+
+plugin_eslint()
diff --git a/web/gr-image-diff-tool/gr-image-diff-tool.ts b/web/gr-image-diff-tool/gr-image-diff-tool.ts
new file mode 100644
index 0000000..e11bbdd
--- /dev/null
+++ b/web/gr-image-diff-tool/gr-image-diff-tool.ts
@@ -0,0 +1,186 @@
+/**
+ * @license
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {html, css, LitElement, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {ImageInfo} from '../gr-opacity-diff-mode/gr-opacity-diff-mode';
+import '../gr-opacity-diff-mode/gr-opacity-diff-mode';
+import '../gr-resemble-diff-mode/gr-resemble-diff-mode';
+
+const DiffModes = {
+  OPACITY: 'opacity',
+  RESEMBLE: 'resemble',
+};
+
+@customElement('gr-image-diff-tool')
+export class ImageDiffTool extends LitElement {
+  static override get styles() {
+    return [
+      css`
+        :host {
+          background-color: var(--table-header-background-color, #fafafa);
+          display: block;
+          font-family: var(--font-family);
+        }
+        #header {
+          align-items: center;
+          border-bottom: 1px solid var(--border-color, #ddd);
+          border-top: 1px solid var(--border-color, #ddd);
+          display: inline-flex;
+          padding: 0.5em;
+          width: 100%;
+        }
+        h3 {
+          padding: 0 0.5em;
+        }
+        #dropdown {
+          background-color: var(--view-background-color);
+          border: 1px solid var(--border-color);
+          border-radius: 2px;
+          color: var(--primary-text-color);
+          font-size: var(--font-size-normal);
+          height: 2em;
+          margin-left: 1em;
+          padding: 0 0.15em;
+        }
+        .diffmode {
+          align-items: center;
+          display: flex;
+          justify-content: center;
+        }
+      `
+    ];
+  }
+
+  @property({type: Object})
+  baseImage?: ImageInfo;
+
+  @property({type: Object})
+  revisionImage?: ImageInfo;
+
+  @property({type: Boolean, reflect: true})
+  override hidden = false;
+
+  @state()
+  protected showResembleMode = false;
+
+  @state()
+  protected showOpacityMode = false;
+
+  @state()
+  protected observeMode = '';
+
+  override render() {
+    return html`
+      <div id="header">
+        <h3>Image diff</h3>
+        <select
+          .value=${this.observeMode}
+          @change=${this.handleSelectChange}
+          id="dropdown"
+        >
+          <option
+            value="resemble"
+            title="Scale the images to the same size and compute a diff with highlights"
+            >Highlight differences</option
+          >
+          <option
+            value="opacity"
+            title="Overlay the new image over the old and use an opacity control to view the differences"
+            >Onion skin</option
+          >
+        </select>
+      </div>
+      <div class="diffmode">
+        ${this.showResembleMode
+          ? html`
+              <gr-resemble-diff-mode
+                .baseImage=${this.baseImage}
+                .revisionImage=${this.revisionImage}
+              ></gr-resemble-diff-mode>
+            `
+          : ''}
+      </div>
+      <div class="diffmode">
+        ${this.showOpacityMode
+          ? html`
+              <gr-opacity-diff-mode
+                .baseImage=${this.baseImage}
+                .revisionImage=${this.revisionImage}
+              ></gr-opacity-diff-mode>
+            `
+          : ''}
+      </div>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('observeMode')) {
+      this.handleSelect(this.observeMode);
+    }
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    if (!this.baseImage || !this.revisionImage) {
+      // No need to show the diff tool if there are no images.
+      this.hidden = true;
+    }
+    const diff_mode = this.getMode();
+    diff_mode === DiffModes.OPACITY
+      ? this.displayOpacityMode()
+      : this.displayResembleMode();
+  }
+
+  protected getMode() {
+    return window.localStorage.getItem('image-diff-mode');
+  }
+
+  protected setMode(mode: string) {
+    window.localStorage.setItem('image-diff-mode', mode);
+  }
+
+  protected handleSelectChange(e: Event) {
+    this.observeMode = (e.target as HTMLSelectElement).value;
+  }
+
+  protected handleSelect(mode: string) {
+    mode === DiffModes.OPACITY
+      ? this.displayOpacityMode()
+      : this.displayResembleMode();
+  }
+
+  protected displayResembleMode() {
+    this.observeMode = DiffModes.RESEMBLE;
+    this.showResembleMode = true;
+    this.showOpacityMode = false;
+    this.setMode(DiffModes.RESEMBLE);
+  }
+
+  protected displayOpacityMode() {
+    this.observeMode = DiffModes.OPACITY;
+    this.showResembleMode = false;
+    this.showOpacityMode = true;
+    this.setMode(DiffModes.OPACITY);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-image-diff-tool': ImageDiffTool;
+  }
+}
diff --git a/web/gr-image-diff-tool/gr-image-diff-tool_test.ts b/web/gr-image-diff-tool/gr-image-diff-tool_test.ts
new file mode 100644
index 0000000..c763123
--- /dev/null
+++ b/web/gr-image-diff-tool/gr-image-diff-tool_test.ts
@@ -0,0 +1,85 @@
+/**
+ * @license
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+import './gr-image-diff-tool';
+import {ImageDiffTool} from './gr-image-diff-tool';
+import {fixture, html, assert} from '@open-wc/testing';
+import sinon from 'sinon';
+
+suite('gr-image-diff-tool tests', () => {
+  let element: ImageDiffTool;
+  let sandbox: sinon.SinonSandbox;
+
+  setup(async () => {
+    sandbox = sinon.createSandbox();
+    (window as any).resemble = {
+      outputSettings: sandbox.stub(),
+      compareTo: sandbox.stub().returnsThis(),
+      onComplete: sandbox.stub(),
+    };
+    const resembleStub: any = sandbox.stub().returns((window as any).resemble);
+    resembleStub.outputSettings = (window as any).resemble.outputSettings;
+    (window as any).resemble = resembleStub;
+    element = await fixture<ImageDiffTool>(
+      html`<gr-image-diff-tool></gr-image-diff-tool>`
+    );
+    await element.updateComplete;
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('test opacity mode', async () => {
+    (element as any).observeMode = 'opacity';
+    await element.updateComplete;
+    await element.updateComplete;
+    assert.isTrue((element as any).showOpacityMode);
+    assert.isFalse((element as any).showResembleMode);
+
+    assert.isOk(element.shadowRoot!.querySelector('gr-opacity-diff-mode'));
+    assert.isNull(element.shadowRoot!.querySelector('gr-resemble-diff-mode'));
+  });
+
+  test('test resemble mode', async () => {
+    (element as any).observeMode = 'resemble';
+    await element.updateComplete;
+    await element.updateComplete;
+    assert.isTrue((element as any).showResembleMode);
+    assert.isFalse((element as any).showOpacityMode);
+
+    assert.isOk(element.shadowRoot!.querySelector('gr-resemble-diff-mode'));
+    assert.isNull(element.shadowRoot!.querySelector('gr-opacity-diff-mode'));
+  });
+
+  test('localStorage persists', async () => {
+    sandbox.stub(element as any, 'setMode');
+    sandbox.stub(element as any, 'getMode');
+    element.connectedCallback();
+    assert.equal((element as any).getMode.callCount, 1);
+    assert.equal((element as any).setMode.callCount, 1);
+    (element as any).observeMode = 'opacity';
+    await element.updateComplete;
+    await element.updateComplete;
+    assert.equal((element as any).setMode.callCount, 2);
+    (element as any).observeMode = 'resemble';
+    await element.updateComplete;
+    await element.updateComplete;
+    assert.equal((element as any).setMode.callCount, 3);
+  });
+});
diff --git a/web/gr-opacity-diff-mode/gr-opacity-diff-mode.ts b/web/gr-opacity-diff-mode/gr-opacity-diff-mode.ts
new file mode 100644
index 0000000..4280139
--- /dev/null
+++ b/web/gr-opacity-diff-mode/gr-opacity-diff-mode.ts
@@ -0,0 +1,199 @@
+/**
+ * @license
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {html, css, LitElement, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+
+export interface ImageInfo {
+  type: string;
+  body: string;
+}
+
+@customElement('gr-opacity-diff-mode')
+export class OpacityDiffMode extends LitElement {
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+        }
+        .wrapper {
+          box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+          margin: 1em 0;
+        }
+        img {
+          display: block;
+          height: var(--img-height);
+          margin: auto;
+          position: absolute;
+          width: var(--img-width);
+        }
+        #imageRevision {
+          opacity: var(--my-opacity-value);
+          z-index: 0.5;
+        }
+        #imageDiffContainer {
+          height: var(--div-height);
+          margin: auto;
+          width: var(--div-width);
+        }
+        #controlsContainer {
+          border-top: 1px solid var(--border-color, #ddd);
+          display: flex;
+        }
+        #controlsBox {
+          display: flex;
+          justify-content: space-between;
+          margin: 0 0.5em;
+          min-width: 32em;
+          width: 100%;
+        }
+        label {
+          align-items: center;
+          display: flex;
+          padding: 1em 0.5em;
+        }
+        input {
+          margin: 0.5em;
+        }
+        #opacitySlider {
+          width: 10em;
+        }
+      `
+    ];
+  }
+
+  @property({type: Object})
+  baseImage?: ImageInfo;
+
+  @property({type: Object})
+  revisionImage?: ImageInfo;
+
+  @property({type: Number})
+  opacityValue = 0.5;
+
+  @state()
+  protected maxHeight = 0;
+
+  @state()
+  protected maxWidth = 0;
+
+  @state()
+  protected scaledWidth: number | null = null;
+
+  @state()
+  protected scaledHeight: number | null = null;
+
+  override render() {
+    return html`
+      <div
+        class="wrapper"
+        style="
+          --my-opacity-value: ${this.opacityValue};
+          --img-width: ${this.scaledWidth ? this.scaledWidth + 'px' : 'initial'};
+          --img-height: ${this.scaledHeight ? this.scaledHeight + 'px' : 'initial'};
+          --div-width: ${this.maxWidth ? this.maxWidth + 'px' : 'initial'};
+          --div-height: ${this.maxHeight ? this.maxHeight + 'px' : 'initial'};
+      "
+      >
+        <div id="imageDiffContainer">
+          <img
+            @load=${this.onImageLoad}
+            id="imageBase"
+            src=${this.computeSrcString(this.baseImage)}
+          />
+          <img
+            @load=${this.onImageLoad}
+            data-opacity=${this.opacityValue}
+            id="imageRevision"
+            src=${this.computeSrcString(this.revisionImage)}
+          />
+        </div>
+        <div id="controlsContainer">
+          <div id="controlsBox">
+            <label>
+              <input
+                id="scaleSizesToggle"
+                @click=${this.handleScaleSizesToggle}
+                type="checkbox"
+              />
+              Scale to same size
+            </label>
+            <label>
+              Revision image opacity
+              <input
+                id="opacitySlider"
+                max="1.0"
+                min="0.0"
+                @input=${this.handleOpacityChange}
+                step=".01"
+                .value=${String(this.opacityValue ?? 0.5)}
+              />
+            </label>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('baseImage') ||
+      changedProperties.has('revisionImage')
+    ) {
+      this.handleImageChange();
+    }
+  }
+
+  protected onImageLoad(e: Event) {
+    const target = e.target as HTMLImageElement;
+    this.maxHeight = Math.max(this.maxHeight, target.naturalHeight);
+    this.maxWidth = Math.max(this.maxWidth, target.naturalWidth);
+  }
+
+  protected handleImageChange() {
+    if (this.baseImage === undefined || this.revisionImage === undefined) return;
+    this.handleOpacityChange();
+  }
+
+  handleOpacityChange(e?: Event) {
+    const value = e ? (e.target as HTMLInputElement).value : this.opacityValue;
+    this.opacityValue = Number(value);
+  }
+
+  computeSrcString(image?: ImageInfo) {
+    if (!image) return '';
+    return 'data:' + image.type + ';base64, ' + image.body;
+  }
+
+  handleScaleSizesToggle(e?: Event) {
+    const isChecked = e ? (e.target as HTMLInputElement).checked : false;
+    if (isChecked) {
+      this.scaledWidth = this.maxWidth;
+      this.scaledHeight = this.maxHeight;
+    } else {
+      this.scaledWidth = null;
+      this.scaledHeight = null;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-opacity-diff-mode': OpacityDiffMode;
+  }
+}
diff --git a/web/gr-opacity-diff-mode/gr-opacity-diff-mode_test.ts b/web/gr-opacity-diff-mode/gr-opacity-diff-mode_test.ts
new file mode 100644
index 0000000..613b578
--- /dev/null
+++ b/web/gr-opacity-diff-mode/gr-opacity-diff-mode_test.ts
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+import './gr-opacity-diff-mode';
+import {OpacityDiffMode} from './gr-opacity-diff-mode';
+import {fixture, html, assert} from '@open-wc/testing';
+
+function getComputedStyleValue(name: string, el: HTMLElement) {
+  let style;
+  if ((window as any).ShadyCSS) {
+    style = (window as any).ShadyCSS.getComputedStyleValue(el, name);
+  } else if ((el as any).getComputedStyleValue) {
+    style = ((el as any).getComputedStyleValue(name));
+  } else {
+    style = getComputedStyle(el).getPropertyValue(name);
+  }
+  return style;
+}
+
+suite('gr-opacity-diff-tool tests', () => {
+  let element: OpacityDiffMode;
+
+  setup(async () => {
+    element = await fixture<OpacityDiffMode>(
+      html`<gr-opacity-diff-mode></gr-opacity-diff-mode>`
+    );
+  });
+
+  test('slider changes opacity of image', async () => {
+    const arrayOfNumbers = [0.73, 0.5, 0.0];
+    const opacitySlider = element.shadowRoot!.querySelector(
+      '#opacitySlider'
+    ) as HTMLInputElement;
+    const wrapper = element.shadowRoot!.querySelector('.wrapper') as HTMLElement;
+    for (const number of arrayOfNumbers) {
+      assert.notEqual(getComputedStyleValue('--my-opacity-value', wrapper), String(number));
+      opacitySlider.value = String(number);
+      // Mock firing input event
+      opacitySlider.dispatchEvent(new Event('input'));
+      element.handleOpacityChange({target: opacitySlider} as any);
+      await element.updateComplete;
+      assert.equal(getComputedStyleValue('--my-opacity-value', wrapper).trim(), String(number));
+    }
+  });
+
+  test('create the src string for images', () => {
+    element.revisionImage = {
+      type: 'IMG/src',
+      body: 'Zx3Cgffk=',
+    };
+    const expectedOutcome = 'data:IMG/src;base64, Zx3Cgffk=';
+    assert.equal(element.computeSrcString(element.revisionImage), expectedOutcome);
+  });
+
+  test('size scaling', async () => {
+    (element as any).maxWidth = 200;
+    (element as any).maxHeight = 400;
+    await element.updateComplete;
+    
+    const wrapper = element.shadowRoot!.querySelector('.wrapper') as HTMLElement;
+    assert.equal(getComputedStyleValue('--img-width', wrapper).trim(), '');
+    assert.equal(getComputedStyleValue('--img-height', wrapper).trim(), '');
+    const scaleSizesToggle = element.shadowRoot!.querySelector(
+      '#scaleSizesToggle'
+    ) as HTMLInputElement;
+
+    scaleSizesToggle.checked = true;
+    scaleSizesToggle.dispatchEvent(new Event('click'));
+    element.handleScaleSizesToggle({target: scaleSizesToggle} as any);
+    await element.updateComplete;
+
+    assert.equal(getComputedStyleValue('--img-width', wrapper).trim(), '200px');
+    assert.equal(getComputedStyleValue('--img-height', wrapper).trim(), '400px');
+  });
+
+  test('resize the div container for base & revision image comparison', async () => {
+    const wrapper = element.shadowRoot!.querySelector('.wrapper') as HTMLElement;
+    assert.equal(getComputedStyleValue('--div-height', wrapper).trim(), '');
+    (element as any).maxHeight = 500;
+    (element as any).maxWidth = 300;
+    await element.updateComplete;
+
+    assert.equal(getComputedStyleValue('--div-height', wrapper).trim(), '500px');
+  });
+});
diff --git a/web/gr-resemble-diff-mode/gr-resemble-diff-mode.ts b/web/gr-resemble-diff-mode/gr-resemble-diff-mode.ts
new file mode 100644
index 0000000..5a289df
--- /dev/null
+++ b/web/gr-resemble-diff-mode/gr-resemble-diff-mode.ts
@@ -0,0 +1,286 @@
+/**
+ * @license
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {html, css, LitElement, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {ImageInfo} from '../gr-opacity-diff-mode/gr-opacity-diff-mode';
+
+const DEFAULT_SETTING = {
+  errorType: 'flat',
+  largeImageThreshold: 1200,
+};
+
+@customElement('gr-resemble-diff-mode')
+export class ResembleDiffMode extends LitElement {
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+        }
+        h2 {
+          display: none;
+        }
+        :host([loading]) #imageDiff {
+          display: none;
+        }
+        :host([loading]) h2 {
+          display: inline;
+          padding: 1em 0;
+        }
+        .toggle {
+          padding: 0.5em;
+        }
+        #controlsContainer {
+          align-items: center;
+          border-top: 1px solid var(--border-color, #ddd);
+          display: flex;
+          justify-content: space-between;
+          padding: 1em;
+          white-space: nowrap;
+          width: 100%;
+        }
+        #diffContainer {
+          display: block;
+          width: 100%;
+        }
+        #color {
+          border: 1px solid var(--border-color, #ddd);
+          border-radius: 2px;
+        }
+        #fullscreen {
+          border: 1px solid var(--border-color, #ddd);
+          border-radius: 2px;
+          color: var(--primary-text-color, #000);
+          padding: 0.5em;
+        }
+        #controlsContainer,
+        #color,
+        #fullscreen {
+          background-color: var(--table-header-background-color, #fafafa);
+        }
+        #color:hover,
+        #fullscreen:hover {
+          background-color: var(--header-background-color, #eeeeee);
+        }
+        #imageDiff {
+          display: block;
+          height: auto;
+          margin: auto;
+          max-width: 50em;
+        }
+        #modeContainer {
+          box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+          display: block;
+          margin: 1em 0em;
+          width: 50em;
+        }
+      `
+    ];
+  }
+
+  @property({type: Object})
+  baseImage?: ImageInfo;
+
+  @property({type: Object})
+  revisionImage?: ImageInfo;
+
+  @state()
+  protected colorValue = '#00ffff';
+
+  @state()
+  protected difference = 0;
+
+  @state()
+  protected ignoreColors = false;
+
+  @state()
+  protected transparent = false;
+
+  @state()
+  protected diffImageSrc = '';
+
+  @property({type: Boolean, reflect: true})
+  loading = false;
+
+  override render() {
+    return html`
+      <div id="modeContainer">
+        <div id="diffContainer">
+          <h2>Loading...</h2>
+          <img id="imageDiff" src=${this.diffImageSrc || ''} />
+        </div>
+        <div id="controlsContainer">
+          <div>${this.difference}% changed</div>
+          <label class="toggle">
+            <input
+              id="ignoreColorsToggle"
+              type="checkbox"
+              .checked=${this.ignoreColors}
+              @click=${this.handleIgnoreColorsToggle}
+            />
+            Ignore colors
+          </label>
+          <label class="toggle">
+            <input
+              id="transparentToggle"
+              type="checkbox"
+              .checked=${this.transparent}
+              @click=${this.handleTransparentToggle}
+            />
+            Transparent
+          </label>
+          <input
+            id="color"
+            type="color"
+            .value=${this.colorValue}
+            @change=${this.handleColorChange}
+          />
+          <button id="fullscreen" @click=${this.handleFullScreen}>
+            View full sized
+          </button>
+        </div>
+      </div>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (
+      changedProperties.has('baseImage') ||
+      changedProperties.has('revisionImage')
+    ) {
+      this.handleImageDiff(this.baseImage, this.revisionImage);
+    }
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    window.resemble.outputSettings(DEFAULT_SETTING);
+  }
+
+  protected handleImageDiff(baseImage?: ImageInfo, revisionImage?: ImageInfo) {
+    if (baseImage === undefined || revisionImage === undefined) {
+      return;
+    }
+    this.reload();
+  }
+
+  protected setImageDiffSrc(src: string) {
+    this.diffImageSrc = src;
+  }
+
+  protected setDifferenceValue(percentage: number) {
+    this.difference = percentage;
+  }
+
+  protected getDataUrl(image: ImageInfo) {
+    return 'data:' + image.type + ';base64,' + image.body;
+  }
+
+  protected maybeIgnoreColors(diffProcess: any, ignoreColors: boolean) {
+    ignoreColors ? diffProcess.ignoreColors() : diffProcess.ignoreNothing();
+    return diffProcess;
+  }
+
+  protected createDiffProcess(base: string, rev: string, ignoreColors: boolean) {
+    window.resemble.outputSettings(this.setOutputSetting());
+    const process = window.resemble(base).compareTo(rev);
+    return this.maybeIgnoreColors(process, ignoreColors);
+  }
+
+  protected setOutputSetting() {
+    const rgb = this.hexToRGB(this.colorValue);
+    return {
+      transparency: this.transparent ? 0.1 : 1,
+      errorColor: {
+        red: rgb?.r ?? 0,
+        green: rgb?.g ?? 255,
+        blue: rgb?.b ?? 255,
+      },
+    };
+  }
+
+  /**
+   * Reloads the diff. Resemble 1.2.1 seems to have an issue with successive
+   * reloads via the repaint() function, so this implementation creates a
+   * fresh diff each time it is called.
+   *
+   * @return resolves if and when the reload succeeds.
+   */
+  reload(): Promise<void> | void {
+    this.loading = true;
+    if (this.baseImage && this.revisionImage) {
+      const base = this.getDataUrl(this.baseImage);
+      const rev = this.getDataUrl(this.revisionImage);
+
+      return new Promise((resolve) => {
+        this.createDiffProcess(base, rev, this.ignoreColors).onComplete(
+          (data: any) => {
+            this.setImageDiffSrc(data.getImageDataUrl());
+            this.setDifferenceValue(data.misMatchPercentage);
+            this.loading = false;
+            resolve();
+          }
+        );
+      });
+    }
+    this.loading = false;
+  }
+
+  protected handleIgnoreColorsToggle(e: Event) {
+    this.ignoreColors = (e.target as HTMLInputElement).checked;
+    this.reload();
+  }
+
+  protected handleTransparentToggle(e: Event) {
+    this.transparent = (e.target as HTMLInputElement).checked;
+    this.reload();
+  }
+
+  protected handleColorChange(e: Event) {
+    this.colorValue = (e.target as HTMLInputElement).value;
+    this.reload();
+  }
+
+  protected hexToRGB(hex: string) {
+    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+    return result
+      ? {
+          r: parseInt(result[1], 16),
+          g: parseInt(result[2], 16),
+          b: parseInt(result[3], 16),
+        }
+      : null;
+  }
+
+  protected handleFullScreen() {
+    const w = window.open('about:blank', '_blank');
+    const imageDiff = this.shadowRoot?.getElementById('imageDiff');
+    if (imageDiff && w) {
+      w.document.body.appendChild(imageDiff.cloneNode(true));
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-resemble-diff-mode': ResembleDiffMode;
+  }
+  interface Window {
+    resemble: any;
+  }
+}
diff --git a/web/gr-resemble-diff-mode/gr-resemble-diff-mode_test.ts b/web/gr-resemble-diff-mode/gr-resemble-diff-mode_test.ts
new file mode 100644
index 0000000..d3de197
--- /dev/null
+++ b/web/gr-resemble-diff-mode/gr-resemble-diff-mode_test.ts
@@ -0,0 +1,172 @@
+/**
+ * @license
+ * Copyright (C) 2026 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+import './gr-resemble-diff-mode';
+import {ResembleDiffMode} from './gr-resemble-diff-mode';
+import {fixture, html, assert} from '@open-wc/testing';
+import sinon from 'sinon';
+
+suite('gr-resemble-diff-mode tests', () => {
+  let element: ResembleDiffMode;
+  let sandbox: sinon.SinonSandbox;
+
+  setup(async () => {
+    sandbox = sinon.createSandbox();
+    (window as any).resemble = {
+      outputSettings: sandbox.stub(),
+      compareTo: sandbox.stub().returnsThis(),
+      onComplete: sandbox.stub(),
+    };
+    const resembleStub: any = sandbox.stub().returns((window as any).resemble);
+    resembleStub.outputSettings = (window as any).resemble.outputSettings;
+    (window as any).resemble = resembleStub;
+    element = await fixture<ResembleDiffMode>(
+      html`<gr-resemble-diff-mode></gr-resemble-diff-mode>`
+    );
+  });
+
+  teardown(() => {
+    sandbox.restore();
+  });
+
+  test('test initialization', () => {
+    assert.isOk(element);
+    assert.isUndefined(element.baseImage);
+    assert.isUndefined(element.revisionImage);
+  });
+
+  test('setImageDiffSrc', async () => {
+    const img = element.shadowRoot!.querySelector('#imageDiff') as HTMLImageElement;
+    const src = 'data:image/png;base64,SGVsbG8=';
+    assert.notOk(img.getAttribute('src'));
+    (element as any).setImageDiffSrc(src);
+    await element.updateComplete;
+    assert.equal(img.getAttribute('src'), src);
+  });
+
+  test('setDifferenceValue', () => {
+    const expected = 0.15;
+    (element as any).setDifferenceValue(expected);
+    assert.equal((element as any).difference, expected);
+  });
+
+  test('maybeIgnoreColors', () => {
+    const dummyProcess = {
+      ignoreColors: sandbox.stub(),
+      ignoreNothing: sandbox.stub(),
+    };
+    (element as any).maybeIgnoreColors(dummyProcess, false);
+    assert.isFalse(dummyProcess.ignoreColors.called);
+    assert.isTrue(dummyProcess.ignoreNothing.called);
+    (element as any).maybeIgnoreColors(dummyProcess, true);
+    assert.isTrue(dummyProcess.ignoreColors.called);
+  });
+
+  test('setOutputSetting', async () => {
+    (element as any).transparent = true;
+    (element as any).colorValue = '#00ffff';
+    const expectedResult = {
+      transparency: 0.1,
+      errorColor: {
+        red: 0,
+        green: 255,
+        blue: 255,
+      },
+    };
+    try {
+      (element as any).createDiffProcess('base', 'rev', false);
+    } catch(e) { /* might throw because of dummy image data, we only care about outputSettings call */ }
+    await element.updateComplete;
+
+    assert.isTrue((window.resemble.outputSettings as any).called);
+    sinon.assert.calledWith(window.resemble.outputSettings as any, expectedResult);
+  });
+
+  test('handleIgnoreColorsToggle', async () => {
+    sandbox.stub(element, 'reload');
+    (element as any).ignoreColors = false;
+    const ignoreColorsToggle = element.shadowRoot!.getElementById('ignoreColorsToggle') as HTMLInputElement;
+    assert.isFalse(ignoreColorsToggle.checked);
+    ignoreColorsToggle.checked = true;
+    ignoreColorsToggle.dispatchEvent(new Event('click'));
+    (element as any).handleIgnoreColorsToggle({target: ignoreColorsToggle} as any);
+    await element.updateComplete;
+
+    assert.isTrue((element as any).ignoreColors);
+    assert.isTrue((element.reload as any).called);
+  });
+
+  test('handleTransparentToggle', async () => {
+    sandbox.stub(element, 'reload');
+    (element as any).transparent = false;
+    const transparentToggle = element.shadowRoot!.getElementById('transparentToggle') as HTMLInputElement;
+    assert.isFalse(transparentToggle.checked);
+    transparentToggle.checked = true;
+    transparentToggle.dispatchEvent(new Event('click'));
+    (element as any).handleTransparentToggle({target: transparentToggle} as any);
+    await element.updateComplete;
+
+    assert.isTrue((element as any).transparent);
+    assert.isTrue((element.reload as any).called);
+  });
+
+  test('handleColorChange', async () => {
+    sandbox.stub(element, 'reload');
+    (element as any).colorValue = '';
+    await element.updateComplete;
+
+    (element as any).handleColorChange({target: {value: '#ffffff'}} as any);
+    assert.isTrue((element.reload as any).called);
+  });
+
+  test('hexToRGB', () => {
+    const expected = {r: 0, g: 255, b: 255};
+    const input = '#00ffff';
+    const output = (element as any).hexToRGB(input);
+    assert.equal(output.r, expected.r);
+    assert.equal(output.g, expected.g);
+    assert.equal(output.b, expected.b);
+  });
+
+  test('handleFullScreen', async () => {
+    const windowOpenStub = sandbox.stub(window, 'open').returns(null);
+    const fullscreenButton = element.shadowRoot!.getElementById('fullscreen') as HTMLButtonElement;
+    fullscreenButton.click();
+    await element.updateComplete;
+
+    assert.isTrue(windowOpenStub.calledWith('about:blank', '_blank'));
+  });
+
+  test('calls reload', async () => {
+    sandbox.stub(element, 'reload');
+    element.baseImage = {type: 'IMG/src', body: ''};
+    await element.updateComplete;
+    assert.equal((element.reload as any).callCount, 0);
+    element.revisionImage = {type: 'IMG/src', body: ''};
+    await element.updateComplete;
+    assert.equal((element.reload as any).callCount, 1);
+    const ignoreColorsToggle = element.shadowRoot!.getElementById('ignoreColorsToggle') as HTMLInputElement;
+    ignoreColorsToggle.click();
+    await element.updateComplete;
+    assert.equal((element.reload as any).callCount, 2);
+    const transparentToggle = element.shadowRoot!.getElementById('transparentToggle') as HTMLInputElement;
+    transparentToggle.click();
+    await element.updateComplete;
+    assert.equal((element.reload as any).callCount, 3);
+  });
+});
diff --git a/web/karma_test.sh b/web/karma_test.sh
new file mode 100755
index 0000000..8d4a34d
--- /dev/null
+++ b/web/karma_test.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+export PATH="/opt/homebrew/bin:$PATH"
+set -euo pipefail
+./$1 --config $2 \
+  --test-files 'plugins/image-diff/web/_bazel_ts_out_tests/**/*_test.js' \
+  --ts-config="plugins/image-diff/web/tsconfig.json"
diff --git a/plugin.js b/web/plugin.ts
similarity index 73%
rename from plugin.js
rename to web/plugin.ts
index 83c7b61..c02ceae 100644
--- a/plugin.js
+++ b/web/plugin.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2026 The Android Open Source Project
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -14,10 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '@gerritcodereview/typescript-api/gerrit';
+import './gr-image-diff-tool/gr-image-diff-tool';
 
-import 'resemblejs/resemble.js'; // eslint-disable-line
-import './gr-image-diff-tool/gr-image-diff-tool.js';
+import 'resemblejs/resemble.js';
 
-Gerrit.install(plugin => {
+window.Gerrit?.install(plugin => {
   plugin.registerCustomComponent('image-diff', 'gr-image-diff-tool');
-});
\ No newline at end of file
+});
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..d8ebe28
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig-plugins-base.json",
+  "compilerOptions": {
+    "outDir": "../../../.ts-out/plugins/image-diff" /* overridden by bazel */
+  },
+  "include": [
+    "**/*.ts"
+  ]
+}