Convert from Polymer to Lit

Change-Id: I4486450eef72d97edb9c9bb265ed9f41b2ca688f
diff --git a/.eslintignore b/.eslintignore
deleted file mode 100644
index e69de29..0000000
--- a/.eslintignore
+++ /dev/null
diff --git a/.eslintrc.json b/.eslintrc.json
deleted file mode 100644
index 6d9ae7c..0000000
--- a/.eslintrc.json
+++ /dev/null
@@ -1,166 +0,0 @@
-{
-  "extends": ["eslint:recommended", "google"],
-  "parserOptions": {
-    "ecmaVersion": 8,
-    "sourceType": "module"
-  },
-  "env": {
-    "browser": true,
-    "es6": true
-  },
-  "globals": {
-    "__dirname": false,
-    "app": false,
-    "page": false,
-    "Polymer": false,
-    "process": false,
-    "require": false,
-    "Gerrit": false,
-    "Promise": false,
-    "assert": false,
-    "test": false,
-    "flushAsynchronousOperations": false
-  },
-  "rules": {
-    "arrow-parens": ["error", "as-needed"],
-    "block-spacing": ["error", "always"],
-    "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
-    "camelcase": "off",
-    "comma-dangle": ["error", {
-      "arrays": "always-multiline",
-      "objects": "always-multiline",
-      "imports": "always-multiline",
-      "exports": "always-multiline",
-      "functions": "never"
-    }],
-    "eol-last": "off",
-    "indent": ["error", 2, {
-      "MemberExpression": 2,
-      "FunctionDeclaration": {"body": 1, "parameters": 2},
-      "FunctionExpression": {"body": 1, "parameters": 2},
-      "CallExpression": {"arguments": 2 },
-      "ArrayExpression": 1,
-      "ObjectExpression": 1,
-      "SwitchCase": 1
-    }],
-    "keyword-spacing": ["error", { "after": true, "before": true }],
-    "lines-between-class-members": ["error", "always"],
-    "max-len": [
-      "error",
-      80,
-      2,
-      {
-        "ignoreComments": true,
-        "ignorePattern": "^import .*;$"
-      }
-    ],
-    "new-cap": ["error", { "capIsNewExceptions": ["Polymer", "LegacyElementMixin", "GestureEventListeners", "LegacyDataMixin"] }],
-    "no-console": "off",
-    "no-multiple-empty-lines": [ "error", { "max": 1 } ],
-    "no-prototype-builtins": "off",
-    "no-redeclare": "off",
-    "no-restricted-syntax": [
-      "error",
-      {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
-        "message": "Remove test.only."
-      },
-      {
-        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
-        "message": "Remove suite.only."
-      }
-    ],
-    "no-undef": "off",
-    "no-useless-escape": "off",
-    "no-var": "error",
-    "object-shorthand": ["error", "always"],
-    "padding-line-between-statements": [
-      "error",
-      {
-        "blankLine": "always",
-        "prev": "class",
-        "next": "*"
-      },
-      {
-        "blankLine": "always",
-        "prev": "*",
-        "next": "class"
-      }
-    ],
-    "prefer-arrow-callback": "error",
-    "prefer-const": "error",
-    "prefer-spread": "error",
-    "quote-props": ["error", "consistent-as-needed"],
-    "semi": [2, "always"],
-    "template-curly-spacing": "error",
-    "valid-jsdoc": "off",
-
-    "require-jsdoc": 0,
-    "valid-jsdoc": 0,
-    "jsdoc/check-alignment": 2,
-    "jsdoc/check-examples": 0,
-    "jsdoc/check-indentation": 0,
-    "jsdoc/check-param-names": 0,
-    "jsdoc/check-syntax": 0,
-    "jsdoc/check-tag-names": 0,
-    "jsdoc/check-types": 0,
-    "jsdoc/implements-on-classes": 2,
-    "jsdoc/match-description": 0,
-    "jsdoc/newline-after-description": 2,
-    "jsdoc/no-types": 0,
-    "jsdoc/no-undefined-types": 0,
-    "jsdoc/require-description": 0,
-    "jsdoc/require-description-complete-sentence": 0,
-    "jsdoc/require-example": 0,
-    "jsdoc/require-hyphen-before-param-description": 0,
-    "jsdoc/require-jsdoc": 0,
-    "jsdoc/require-param": 0,
-    "jsdoc/require-param-description": 0,
-    "jsdoc/require-param-name": 2,
-    "jsdoc/require-param-type": 2,
-    "jsdoc/require-returns": 0,
-    "jsdoc/require-returns-check": 0,
-    "jsdoc/require-returns-description": 0,
-    "jsdoc/require-returns-type": 2,
-    "jsdoc/valid-types": 2,
-    "jsdoc/require-file-overview": ["error", {
-      "tags": {
-        "license": {
-          "mustExist": true,
-          "preventDuplicates": true
-        }
-      }
-    }],
-    "import/named": 2,
-    "import/no-unresolved": 2,
-    "import/no-self-import": 2,
-    // The no-cycle rule is slow, because it doesn't cache dependencies.
-    // Disable it.
-    "import/no-cycle": 0,
-    "import/no-useless-path-segments": 2,
-    "import/no-unused-modules": 2,
-    "import/no-default-export": 2
-  },
-  "plugins": [
-    "html",
-    "jsdoc",
-    "import"
-  ],
-  "settings": {
-    "html/report-bad-indent": "error"
-  },
-  "overrides": [
-    {
-      "files": ["*_html.js", "*-styles.js", "externs.js"],
-      "rules": {
-        "max-len": "off"
-      }
-    },
-    {
-      "files": ["*.html"],
-      "rules": {
-        "jsdoc/require-file-overview": "off"
-      }
-    }
-  ]
-}
diff --git a/BUILD b/BUILD
index 3c5aaa8..4070f5a 100644
--- a/BUILD
+++ b/BUILD
@@ -1,15 +1,11 @@
 load("@rules_java//java:defs.bzl", "java_library")
-load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 load("//tools/bzl:junit.bzl", "junit_tests")
-load("//tools/js:eslint.bzl", "eslint")
 load(
     "//tools/bzl:plugin.bzl",
     "PLUGIN_DEPS",
     "PLUGIN_TEST_DEPS",
     "gerrit_plugin",
 )
-load("//tools/bzl:genrule2.bzl", "genrule2")
-load("//tools/bzl:js.bzl", "polygerrit_plugin")
 
 gerrit_plugin(
     name = "zuul",
@@ -21,7 +17,7 @@
         "Gerrit-Module: com.googlesource.gerrit.plugins.zuul.Module",
         "Gerrit-HttpModule: com.googlesource.gerrit.plugins.zuul.HttpModule",
     ],
-    resource_jars = [":gr-zuul-static"],
+    resource_jars = ["//plugins/zuul/web:zuul"],
 )
 
 junit_tests(
@@ -43,55 +39,3 @@
         "@commons-lang3//jar",
     ],
 )
-
-genrule2(
-    name = "gr-zuul-static",
-    srcs = [":gr-zuul"],
-    outs = ["gr-zuul-static.jar"],
-    cmd = " && ".join([
-        "mkdir $$TMP/static",
-        "cp $(locations :gr-zuul) $$TMP/static",
-        "cd $$TMP",
-        "zip -Drq $$ROOT/$@ -g .",
-    ]),
-)
-
-polygerrit_plugin(
-    name = "gr-zuul",
-    app = "zuul-bundle.js",
-    plugin_name = "zuul",
-)
-
-rollup_bundle(
-    name = "zuul-bundle",
-    srcs = glob(["gr-zuul/*.js"]),
-    entry_point = "gr-zuul/plugin.js",
-    rollup_bin = "//tools/node_tools:rollup-bin",
-    sourcemap = "hidden",
-    format = "iife",
-    deps = [
-        "@tools_npm//rollup-plugin-node-resolve",
-    ],
-)
-
-# Define the eslinter for the plugin
-# The eslint macro creates 2 rules: lint_test and lint_bin
-eslint(
-    name = "lint",
-    srcs = glob([
-        "gr-zuul/**/*.js",
-    ]),
-    config = ".eslintrc.json",
-    data = [],
-    extensions = [
-        ".js",
-    ],
-    ignore = ".eslintignore",
-    plugins = [
-        "@npm//eslint-config-google",
-        "@npm//eslint-plugin-html",
-        "@npm//eslint-plugin-import",
-        "@npm//eslint-plugin-jsdoc",
-    ],
-)
-
diff --git a/gr-zuul/gr-zuul.js b/gr-zuul/gr-zuul.js
deleted file mode 100644
index b6893ce..0000000
--- a/gr-zuul/gr-zuul.js
+++ /dev/null
@@ -1,119 +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.
- */
-
-import {htmlTemplate} from './gr-zuul_html.js';
-
-class GrZuul extends Polymer.Element {
-  /** @returns {string} name of the component */
-  static get is() { return 'gr-zuul'; }
-
-  /** @returns {?} template for this component */
-  static get template() { return htmlTemplate; }
-
-  static get properties() {
-    return {
-      change: {
-        type: Object,
-        observer: '_onChangeChanged',
-      },
-      hidden: {
-        type: Boolean,
-        value: true,
-        reflectToAttribute: true,
-      },
-      _crd: {
-        type: Object,
-        value: {},
-      },
-      _crd_loaded: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  _onChangeChanged() {
-    this._crd_loaded = false;
-    this.setHidden(true);
-    const url = '/changes/' + this.change.id + '/revisions/current/crd';
-    return this.plugin.restApi().send('GET', url).then(crd => {
-      this._crd = crd;
-      this._crd_loaded = true;
-      this.setHidden(!(this._isDependsOnSectionVisible()
-                       || crd.needed_by.length));
-    });
-  }
-
-  // copied from gr-related-changes-list.js, which is inaccessible from here.
-  // Resolved uses of `this.ChangeStatus.[...]`, as that's inaccessible from here too.
-  // Removed _isIndirectAncestor check, as the needed data is inaccessible from here.
-  // Not all code paths are reachable, as we only have shallow ChangeInfo objects. We leave the
-  // code here nonetheless, to allow for easier updating from gr-related-changes-list.js.
-  _computeChangeStatusClass(change) {
-    const classes = ['status'];
-    if (change._revision_number != change._current_revision_number) {
-      classes.push('notCurrent');
-    } else if (change.submittable) {
-      classes.push('submittable');
-    } else if (change.status == 'NEW') {
-      classes.push('hidden');
-    }
-    return classes.join(' ');
-  }
-
-  // copied from gr-related-changes-list.js, which is inaccessible from here.
-  // Resolved uses of `this.ChangeStatus.[...]`, as that's inaccessible from here too.
-  // Removed _isIndirectAncestor check, as the needed data is inaccessible from here.
-  // Not all code paths are reachable, as we only have shallow ChangeInfo objects. We leave the
-  // code here nonetheless, to allow for easier updating from gr-related-changes-list.js.
-  _computeChangeStatus(change) {
-    switch (change.status) {
-      case 'MERGED':
-        return 'Merged';
-      case 'ABANDONED':
-        return 'Abandoned';
-    }
-    if (change._revision_number != change._current_revision_number) {
-      return 'Not current';
-    } else if (change.submittable) {
-      return 'Submittable';
-    }
-    return '';
-  }
-
-  setHidden(hidden) {
-    if (this.hidden != hidden) {
-      this.hidden = hidden;
-
-      // Flag to parents that something changed
-      this.dispatchEvent(new CustomEvent('new-section-loaded', {
-        composed: true, bubbles: true,
-      }));
-    }
-  }
-
-  _computeDependencyUrl(changeInfo) {
-    return `${window.CANONICAL_PATH || ''}/q/${changeInfo.change_id}`;
-  }
-
-  _isDependsOnSectionVisible() {
-    return !!(this._crd.depends_on_found.length
-              + this._crd.depends_on_missing.length);
-  }
-}
-
-customElements.define(GrZuul.is, GrZuul);
diff --git a/gr-zuul/gr-zuul_html.js b/gr-zuul/gr-zuul_html.js
deleted file mode 100644
index 5e208d4..0000000
--- a/gr-zuul/gr-zuul_html.js
+++ /dev/null
@@ -1,129 +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.
- */
-
-export const htmlTemplate = Polymer.html`
-    <style include="shared-styles">
-      section.related-changes-section {
-        margin-bottom: 1.4em; /* Same as line height for collapse purposes */
-        display: block;
-      }
-      div.foo {
-        margin-bottom: 1.4em; /* Same as line height for collapse purposes */
-      }
-      a {
-        display: block;
-      }
-      .changeContainer,
-      a {
-        max-width: 100%;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-      .changeContainer {
-        display: flex;
-      }
-      .changeContainer.thisChange:before {
-        content: '➔';
-        width: 1.2em;
-      }
-      h4,
-      section div {
-        display: flex;
-      }
-      h4:before,
-      section div:before {
-        content: ' ';
-        flex-shrink: 0;
-        width: 1.2em;
-      }
-      .status {
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-bold);
-        margin-left: var(--spacing-xs);
-      }
-      /* The above styles are copy/paste from gr-related-changes-list_html.js */
-      .dependencyCycleDetected {
-        color: #d17171;
-      }
-      .missingFromThisServer {
-        color: #d17171;
-      }
-      .hidden {
-        display: none;
-      }
-    </style>
-    <template is="dom-if" if="[[_crd_loaded]]">
-      <template is="dom-if" if="[[_isDependsOnSectionVisible()]]">
-        <section class="related-changes-section">
-          <h4>Depends on</h4>
-          <template is="dom-repeat" items="[[_crd.depends_on_found]]">
-            <div class="changeContainer zuulDependencyContainer">
-              <a
-                href$="[[_computeDependencyUrl(item)]]"
-                title$="[[item.project]]: [[item.branch]]: [[item.subject]]"
-              >
-                [[item.project]]: [[item.branch]]: [[item.subject]]
-              </a>
-              <span class$="[[_computeChangeStatusClass(item)]]">
-                ([[_computeChangeStatus(item)]])
-              </span>
-              <template is="dom-if" if="[[_crd.cycle]]">
-                <span class="status dependencyCycleDetected">
-                  (Dependency cycle detected)
-                </span>
-              </template>
-            </div>
-          </template>
-          <template is="dom-repeat" items="[[_crd.depends_on_missing]]">
-            <div class="changeContainer zuulDependencyContainer">
-              <span>
-                [[item]]
-              </span>
-              <span class="status missingFromThisServer">
-                (Missing from this server)
-              </span>
-            </div>
-          </template>
-        </section>
-      </template>
-      <template is="dom-if" if="[[_crd.needed_by.length]]">
-        <section class="related-changes-section">
-          <h4>Needed by</h4>
-          <template is="dom-repeat" items="[[_crd.needed_by]]">
-            <div class="changeContainer zuulDependencyContainer">
-              <a
-                href$="[[_computeDependencyUrl(item)]]"
-                title$="[[item.project]]: [[item.branch]]: [[item.subject]]"
-              >
-                [[item.project]]: [[item.branch]]: [[item.subject]]
-              </a>
-              <span class$="[[_computeChangeStatusClass(item)]]">
-                ([[_computeChangeStatus(item)]])
-              </span>
-              <template is="dom-if" if="[[_crd.cycle]]">
-                <span class="status dependencyCycleDetected">
-                  (Dependency cycle detected)
-                </span>
-              </template>
-            </div>
-          </template>
-        </section>
-      </template>
-    </template>
-`;
-
diff --git a/web/BUILD b/web/BUILD
new file mode 100644
index 0000000..991390e
--- /dev/null
+++ b/web/BUILD
@@ -0,0 +1,42 @@
+load("//tools/js:eslint.bzl", "plugin_eslint")
+load("//tools/bzl:js.bzl", "gerrit_js_bundle")
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/zuul/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "zuul-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",
+    ],
+)
+
+gerrit_js_bundle(
+    name = "zuul",
+    srcs = [":zuul-ts"],
+    entry_point = "_bazel_ts_out/plugin.js",
+)
+
+plugin_eslint()
diff --git a/web/eslint.config.js b/web/eslint.config.js
new file mode 100644
index 0000000..54028d5
--- /dev/null
+++ b/web/eslint.config.js
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2025 The Android Open Source Project
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const {defineConfig} = require('eslint/config');
+
+// eslint-disable-next-line no-undef
+__plugindir = 'zuul/web';
+
+const gerritEslint = require('../../eslint.config.js');
+
+module.exports = defineConfig([
+  {
+    extends: [gerritEslint],
+  },
+]);
diff --git a/web/gr-zuul.ts b/web/gr-zuul.ts
new file mode 100644
index 0000000..9d28ab2
--- /dev/null
+++ b/web/gr-zuul.ts
@@ -0,0 +1,250 @@
+/**
+ * @license
+ * Copyright (C) 2025 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 {ChangeInfo, NumericChangeId} from '@gerritcodereview/typescript-api/rest-api';
+import {LitElement, html, css, nothing} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-zuul': GrZuul;
+  }
+}
+
+interface CrdInfo {
+  depends_on_found?: ChangeInfo[];
+  depends_on_missing?: string[];
+  needed_by?: ChangeInfo[];
+  cycle?: boolean;
+}
+
+// Partial copy of https://github.com/GerritCodeReview/gerrit/blob/b42341c5cd9b1f1535df30b16f180a90617fd067/polygerrit-ui/app/types/common.ts#L1377
+interface RelatedChangeAndCommitInfo {
+  _change_number?: NumericChangeId;
+  _revision_number?: number;
+  _current_revision_number?: number;
+  status?: string;
+  submittable?: boolean;
+}
+
+
+@customElement('gr-zuul')
+export class GrZuul extends LitElement {
+  @property({type: Object}) change?: ChangeInfo;
+
+  @state() private _crd: CrdInfo = {};
+  @state() private _crdLoaded = false;
+
+  static override get styles() {
+    return [
+      css`
+        section.related-changes-section {
+          margin-bottom: 1.4em;
+          display: block;
+        }
+        a {
+          display: block;
+          max-width: 100%;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .changeContainer {
+          display: flex;
+          max-width: 100%;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .changeContainer.thisChange:before {
+          content: '➔';
+          width: 1.2em;
+        }
+        h4,
+        section div {
+          display: flex;
+        }
+        h4:before,
+        section div:before {
+          content: ' ';
+          flex-shrink: 0;
+          width: 1.2em;
+        }
+        .status {
+          color: var(--deemphasized-text-color);
+          font-weight: var(--font-weight-bold);
+          margin-left: var(--spacing-xs);
+        }
+        .dependencyCycleDetected,
+        .missingFromThisServer {
+          color: #d17171;
+        }
+        .hidden {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this._crdLoaded) return nothing;
+
+    return html`
+      ${this._isDependsOnSectionVisible()
+        ? html`
+            <section class="related-changes-section">
+              <h4>Depends on</h4>
+              ${this._crd.depends_on_found?.map(
+                item => html`
+                  <div class="changeContainer zuulDependencyContainer">
+                    <a
+                      href=${this._computeDependencyUrl(item)}
+                      title="${item.project}: ${item.branch}: ${item.subject}"
+                    >
+                      ${item.project}: ${item.branch}: ${item.subject}
+                    </a>
+                    <span class=${this._computeChangeStatusClass(item)}>
+                      (${this._computeChangeStatus(item)})
+                    </span>
+                    ${this._crd.cycle
+                      ? html`
+                          <span class="status dependencyCycleDetected">
+                            (Dependency cycle detected)
+                          </span>
+                        `
+                      : nothing}
+                  </div>
+                `
+              )}
+              ${this._crd.depends_on_missing?.map(
+                item => html`
+                  <div class="changeContainer zuulDependencyContainer">
+                    <span>${item}</span>
+                    <span class="status missingFromThisServer">
+                      (Missing from this server)
+                    </span>
+                  </div>
+                `
+              )}
+            </section>
+          `
+        : nothing}
+
+      ${this._crd.needed_by?.length
+        ? html`
+            <section class="related-changes-section">
+              <h4>Needed by</h4>
+              ${this._crd.needed_by.map(
+                item => html`
+                  <div class="changeContainer zuulDependencyContainer">
+                    <a
+                      href=${this._computeDependencyUrl(item)}
+                      title="${item.project}: ${item.branch}: ${item.subject}"
+                    >
+                      ${item.project}: ${item.branch}: ${item.subject}
+                    </a>
+                    <span class=${this._computeChangeStatusClass(item)}>
+                      (${this._computeChangeStatus(item)})
+                    </span>
+                    ${this._crd.cycle
+                      ? html`
+                          <span class="status dependencyCycleDetected">
+                            (Dependency cycle detected)
+                          </span>
+                        `
+                      : nothing}
+                  </div>
+                `
+              )}
+            </section>
+          `
+        : nothing}
+    `;
+  }
+
+  override updated(changedProperties: Map<string, unknown>) {
+    if (changedProperties.has('change')) {
+      void this._onChangeChanged();
+    }
+  }
+
+  private async _onChangeChanged(): Promise<void> {
+    this._crdLoaded = false;
+    this._setHidden(true);
+    if (!this.change?.id) return;
+
+    const url = `/changes/${this.change.id}/revisions/current/crd`;
+    const plugin = (this as any).plugin;
+
+    const crd: CrdInfo = await plugin.restApi().send('GET', url);
+    this._crd = crd;
+    this._crdLoaded = true;
+
+    const visible = this._isDependsOnSectionVisible() || (crd.needed_by?.length ?? 0) > 0;
+    this._setHidden(!visible);
+  }
+
+  private _setHidden(hidden: boolean): void {
+    if (this.hidden !== hidden) {
+      this.hidden = hidden;
+      this.dispatchEvent(
+        new CustomEvent('new-section-loaded', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+    }
+  }
+
+  private _computeChangeStatusClass(change: RelatedChangeAndCommitInfo): string {
+    const classes = ['status'];
+    if (change._revision_number !== change._current_revision_number) {
+      classes.push('notCurrent');
+    } else if (change.submittable) {
+      classes.push('submittable');
+    } else if (change.status === 'NEW') {
+      classes.push('hidden');
+    }
+    return classes.join(' ');
+  }
+
+  private _computeChangeStatus(change: RelatedChangeAndCommitInfo): string {
+    switch (change.status) {
+      case 'MERGED':
+        return 'Merged';
+      case 'ABANDONED':
+        return 'Abandoned';
+      default:
+        if (change._revision_number !== change._current_revision_number) {
+          return 'Not current';
+        } else if (change.submittable) {
+          return 'Submittable';
+        }
+        return '';
+    }
+  }
+
+  private _computeDependencyUrl(changeInfo: ChangeInfo): string {
+    const base = (window as any).CANONICAL_PATH || '';
+    return `${base}/q/${changeInfo.change_id}`;
+  }
+
+  private _isDependsOnSectionVisible(): boolean {
+    const {depends_on_found, depends_on_missing} = this._crd;
+    return (depends_on_found?.length ?? 0) + (depends_on_missing?.length ?? 0) > 0;
+  }
+}
diff --git a/gr-zuul/plugin.js b/web/plugin.ts
similarity index 80%
rename from gr-zuul/plugin.js
rename to web/plugin.ts
index 475f053..d75cd7d 100644
--- a/gr-zuul/plugin.js
+++ b/web/plugin.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 The Android Open Source Project
+ * Copyright (C) 2025 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,9 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './gr-zuul.js';
 
-Gerrit.install(plugin => {
+import '@gerritcodereview/typescript-api/gerrit';
+import './gr-zuul';
+
+window.Gerrit?.install(plugin => {
   plugin.registerCustomComponent(
       'related-changes-section', 'gr-zuul', {slot: 'bottom'});
 });
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..78f5cc7
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig-plugins-base.json",
+  "compilerOptions": {
+    "outDir": "../../../.ts-out/plugins/zuul" /* overridden by bazel */
+  },
+  "include": [
+    "**/*.ts"
+  ]
+}