Migrate UI component to Lit

Change-Id: I6342238eac9cd263bc0948b27ea47ac39c4b5fc2
diff --git a/BUILD b/BUILD
index 95ed97f..043a44d 100644
--- a/BUILD
+++ b/BUILD
@@ -18,16 +18,10 @@
         "Implementation-Title: Plugin messageoftheday",
         "Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/messageoftheday",
     ],
-    resource_jars = [":gr-messageoftheday"],
+    resource_jars = ["//plugins/messageoftheday/web:messageoftheday"],
     resources = glob(["src/main/resources/**/*"]),
 )
 
-gerrit_js_bundle(
-    name = "gr-messageoftheday",
-    srcs = glob(["gr-messageoftheday/*.js"]),
-    entry_point = "gr-messageoftheday/plugin.js",
-)
-
 junit_tests(
     name = "messageoftheday_tests",
     srcs = glob(["src/test/java/**/*.java"]),
diff --git a/gr-messageoftheday/gr-messageoftheday-banner.js b/gr-messageoftheday/gr-messageoftheday-banner.js
deleted file mode 100644
index dc9e393..0000000
--- a/gr-messageoftheday/gr-messageoftheday-banner.js
+++ /dev/null
@@ -1,74 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 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-messageoftheday-banner_html.js';
-
-class GrMessageOfTheDayBanner extends Polymer.Element {
-  static get is() {
-    return 'gr-messageoftheday-banner';
-  }
-
-  static get template() {
-    return htmlTemplate;
-  }
-
-  static get properties() {
-    return {
-      _message: Object,
-      _hidden: {
-        type: Boolean,
-        value: true,
-      }
-    };
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-
-    this.plugin.restApi()
-      .get(`/config/server/${this.plugin.getPluginName()}~message`)
-      .then(message => {
-        if (!message || !message.html) {
-          return;
-        }
-        this._message = message;
-        this._isHidden();
-        this.$.message.innerHTML = this._message.html;
-      });
-  }
-
-  _handleDismissMessage() {
-    document.cookie =
-      `msg-${this._message.content_id}=1; path=/; expires=${this._getExpires()}`;
-    this._hidden = true;
-  }
-
-  _isHidden() {
-    this._hidden = document.cookie.search(`msg-${this._message.content_id}=`) > -1;
-  }
-
-  _getExpires() {
-    var date = new Date();
-    date.setHours(0);
-    date.setMinutes(0);
-    date.setSeconds(0);
-    date.setMilliseconds(0);
-    date.setDate(date.getDate() + 1);
-    return date.toUTCString();
-  }
-}
-
-customElements.define(GrMessageOfTheDayBanner.is, GrMessageOfTheDayBanner);
diff --git a/gr-messageoftheday/gr-messageoftheday-banner_html.js b/gr-messageoftheday/gr-messageoftheday-banner_html.js
deleted file mode 100644
index e5a2661..0000000
--- a/gr-messageoftheday/gr-messageoftheday-banner_html.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * @license
- * Copyright (C) 2021 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">
-  #container {
-    background-color: var(--line-item-highlight-color);
-    display: flex;
-    height: fit-content;
-    justify-content: space-between;
-    padding: 1em;
-  }
-  #message {
-    flex-grow: 1;
-  }
-</style>
-<div id="container" hidden$="[[_hidden]]">
-  <div id="message"></div>
-  <gr-button id="dismissMessageBtn"
-    link
-    on-click="_handleDismissMessage">Dismiss</gr-button>
-</div>
-<gr-rest-api-interface id="restAPI"></gr-rest-api-interface>`;
diff --git a/gr-messageoftheday/gr-messageoftheday-edit.js b/gr-messageoftheday/gr-messageoftheday-edit.js
deleted file mode 100644
index 3442e6a..0000000
--- a/gr-messageoftheday/gr-messageoftheday-edit.js
+++ /dev/null
@@ -1,170 +0,0 @@
-/**
- * @license
- * Copyright (C) 2024 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-messageoftheday-edit_html.js';
-
-class GrMessageOfTheDayEdit extends Polymer.Element {
-  static get is() {
-    return 'gr-messageoftheday-edit';
-  }
-
-  static get template() {
-    return htmlTemplate;
-  }
-
-  static get properties() {
-    return {
-      _message: {
-        type: String,
-        observer: '_messageChanged'
-      },
-      _expire_after_value: {
-        type: String,
-      },
-      _expire_after_unit: {
-        type: String,
-        value: 'd',
-      },
-      _can_update: {
-        type: Boolean,
-        value: false,
-      },
-      _show_update_banner: {
-        type: Boolean,
-        value: false,
-      },
-    };
-  }
-
-  connectedCallback() {
-    super.connectedCallback();
-  }
-
-  ready() {
-    super.ready();
-    this._canUpdate();
-  }
-
-  _canUpdate() {
-    const endpoint = `/accounts/self/capabilities?q=messageoftheday-updateBanner`;
-    return this.plugin.restApi().get(endpoint).then(response => {
-      if (response && response['messageoftheday-updateBanner'] === true) {
-        this._can_update = true;
-        this._fetchMessage();
-      }
-    }).catch(error => {
-      console.error('Error checking updateBanner capability:', error);
-      this._can_update = false;
-    });
-  }
-
-  _fetchMessage() {
-    return this.plugin.restApi().get("/config/server/messageoftheday~message").then(response => {
-      if (response) {
-        this._message = response.html;
-      } else {
-        this._message = '';
-      }
-    }).catch(error => {
-      console.error('Error fetching message:', error);
-      this._message = '';
-    });
-  }
-
-  _saveMessage() {
-    const endpoint = `/config/server/messageoftheday~message`;
-    const payload = {
-      message: this._message
-    };
-    if (this._expire_after_value) {
-      payload.expires_at = this._convertToFormattedDate(
-          this._expire_after_value, this._expire_after_unit);
-    }
-
-    return this.plugin.restApi().post(endpoint, payload).then(
-        response => {
-          location.reload();
-        }
-    ).catch(error => {
-      console.error('Error saving message:', error);
-    });
-  }
-
-  _openDialog() {
-    if (!this.$.message_dialog_overlay.open) {
-      this.$.message_dialog_overlay.showModal();
-    }
-    this.$.message_dialog.classList.toggle('invisible', false);
-  }
-
-  _closeDialog() {
-    this.$.message_dialog.classList.toggle('invisible', true);
-    this.$.message_dialog_overlay.close();
-  }
-
-  _onMessageInput(e) {
-    this._message = e.detail.value;
-  }
-
-  _onExpireAfterValueInput(e) {
-    const target = e.target;
-    this._expire_after_value = target.value ?? '';
-  }
-
-  _onExpireAfterUnitChange(e) {
-    const sel = e.target;
-    this._expire_after_unit = sel.value ?? 'd';
-  }
-
-  _messageChanged(newMessage) {
-    const messagePreview = this.shadowRoot.querySelector('#messagePreview');
-    if (messagePreview) {
-      messagePreview.innerHTML = newMessage;
-    }
-  }
-
-  _convertToFormattedDate(value, unit) {
-    let msToAdd = 0;
-    switch (unit) {
-      case 'm':
-        msToAdd = value * 60 * 1000;
-        break;
-      case 'h':
-        msToAdd = value * 60 * 60 * 1000;
-        break;
-      case 'd':
-        msToAdd = value * 24 * 60 * 60 * 1000;
-        break;
-      case 'w':
-        msToAdd = value * 7 * 24 * 60 * 60 * 1000;
-        break;
-    }
-
-    const now = new Date();
-    const future = new Date(now.getTime() + msToAdd);
-    const options = {
-      year: 'numeric',
-      month: '2-digit',
-      day: '2-digit',
-      hour: '2-digit',
-      minute: '2-digit',
-      timeZoneName: 'short'
-    };
-    return new Intl.DateTimeFormat('en-US', options).format(future);
-  }
-}
-
-customElements.define(GrMessageOfTheDayEdit.is, GrMessageOfTheDayEdit);
diff --git a/gr-messageoftheday/gr-messageoftheday-edit_html.js b/gr-messageoftheday/gr-messageoftheday-edit_html.js
deleted file mode 100644
index dacaa45..0000000
--- a/gr-messageoftheday/gr-messageoftheday-edit_html.js
+++ /dev/null
@@ -1,131 +0,0 @@
-/**
- * @license
- * Copyright (C) 2024 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="gr-modal-styles gr-material-styles">
-    input, select {
-      background-color: var(--select-background-color);
-      color: var(--primary-text-color);
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      padding: var(--spacing-s);
-      font: inherit;
-    }
-    gr-autogrow-textarea, #messagePreview {
-      background-color: var(--view-background-color);
-      color: var(--primary-text-color);
-      font: inherit;
-      width: 80ch;
-      height: 25ch;
-      border: 1px solid var(--border-color);
-      border-radius: var(--border-radius);
-      box-sizing: border-box;
-    }
-    gr-autogrow-textarea:focus-within {
-      border: 2px solid var(--input-focus-border-color);
-    }
-    #messagePreview {
-      background-color: var(--background-color-tertiary);
-      overflow-y: auto;
-    }
-    section {
-      margin-bottom: 1em;
-    }
-    md-icon-button {
-      --md-sys-color-on-surface-variant: var(--header-text-color);
-    }
-    md-icon[filled] {
-      font-variation-settings: 'FILL' 1;
-    }
-    .value {
-      display: flex;
-      flex-direction: column;
-      margin-bottom: 10px;
-    }
-    .value > * {
-      margin: 0;
-    }
-    .icon-button {
-      background: none;
-      box-shadow: none;
-      padding: 0;
-      min-width: 0;
-    }
-
-  </style>
-  <template is="dom-if" if="[[_can_update]]">
-    <md-icon-button class="icon-button" on-click="_openDialog">
-      <md-icon filled>campaign</md-icon>
-    </md-icon-button>
-  </template>
-  <dialog id="message_dialog_overlay" tabindex="-1">
-    <gr-dialog id="message_dialog" confirm-label="Save Message"
-        on-confirm="_saveMessage" on-cancel="_closeDialog">
-      <div class="header" slot="header">Set Banner Message</div>
-      <div class="main" slot="main">
-        <section>
-          <span class="title">Expire After:</span>
-          <span class="value">
-            <div style="display: flex; align-items: center; gap: 5px;">
-              <md-outlined-text-field
-                id="expireAfterInput"
-                class="showBlueFocusBorder"
-                placeholder="Enter Number"
-                value="[[_expire_after_value]]"
-                on-input="_onExpireAfterValueInput">
-              </md-outlined-text-field>
-              <md-outlined-select
-                id="expireAfterUnitSelect"
-                value="[[_expire_after_unit]]"
-                on-change="_onExpireAfterUnitChange">
-                <md-select-option value="m">
-                  <div slot="headline">minutes</div>
-                </md-select-option>
-                <md-select-option value="h">
-                  <div slot="headline">hours</div>
-                </md-select-option>
-                <md-select-option value="d">
-                  <div slot="headline">days</div>
-                </md-select-option>
-                <md-select-option value="w">
-                  <div slot="headline">weeks</div>
-                </md-select-option>
-              </md-outlined-select>
-            </div>
-          </span>
-        </section>
-        <section>
-          <span class="title">Message:</span>
-          <span class="value">
-            <gr-autogrow-textarea
-                class="text_area"
-                placeholder="Enter Message"
-                autocomplete="off"
-                value="[[_message]]"
-                on-input="_onMessageInput"/>
-          </span>
-        </section>
-        <section>
-          <span class="title">Preview:</span>
-          <span class="value">
-            <div id="messagePreview" readonly>{{_message}}</div>
-          </span>
-        </section>
-      </div>
-    </gr-dialog>
-  </dialog>
-`;
diff --git a/src/main/java/com/googlesource/gerrit/plugins/messageoftheday/HttpModule.java b/src/main/java/com/googlesource/gerrit/plugins/messageoftheday/HttpModule.java
index 4a43938..911da13 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/messageoftheday/HttpModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/messageoftheday/HttpModule.java
@@ -23,6 +23,6 @@
   @Override
   protected void configure() {
     DynamicSet.bind(binder(), WebUiPlugin.class)
-        .toInstance(new JavaScriptPlugin("gr-messageoftheday.js"));
+        .toInstance(new JavaScriptPlugin("messageoftheday.js"));
   }
 }
diff --git a/web/BUILD b/web/BUILD
new file mode 100644
index 0000000..ed44db1
--- /dev/null
+++ b/web/BUILD
@@ -0,0 +1,64 @@
+load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project")
+load("//tools/bzl:js.bzl", "web_test_runner")
+load("@com_googlesource_gerrit_bazlets//js:defs.bzl", "gerrit_js_bundle")
+load("//tools/js:eslint.bzl", "plugin_eslint")
+
+package_group(
+    name = "visibility",
+    packages = ["//plugins/messageoftheday/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+ts_config(
+    name = "tsconfig",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base",
+    ],
+)
+
+ts_project(
+    name = "messageoftheday-ts",
+    srcs = glob(
+        ["**/*.ts"],
+        exclude = ["**/*test*"],
+    ),
+    incremental = True,
+    out_dir = "_bazel_ts_out",
+    tsconfig = ":tsconfig",
+    deps = [
+        "//plugins:node_modules",
+    ],
+)
+
+ts_project(
+    name = "messageoftheday-ts-tests",
+    srcs = glob(["**/*.ts"]),
+    incremental = True,
+    out_dir = "_bazel_ts_out_tests",
+    tsconfig = ":tsconfig",
+    deps = [
+        "//plugins:node_modules",
+        "//polygerrit-ui:node_modules",
+    ],
+)
+
+gerrit_js_bundle(
+    name = "messageoftheday",
+    srcs = [":messageoftheday-ts"],
+    entry_point = "_bazel_ts_out/plugin.js",
+)
+
+web_test_runner(
+    name = "web_test_runner",
+    srcs = ["web_test_runner.sh"],
+    data = [
+        ":messageoftheday-ts-tests",
+        ":tsconfig",
+        "//plugins:node_modules",
+        "//polygerrit-ui:node_modules",
+    ],
+)
+
+plugin_eslint()
diff --git a/web/gr-messageoftheday-banner.ts b/web/gr-messageoftheday-banner.ts
new file mode 100644
index 0000000..5febc28
--- /dev/null
+++ b/web/gr-messageoftheday-banner.ts
@@ -0,0 +1,134 @@
+/**
+ * @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 { PluginApi } from '@gerritcodereview/typescript-api/plugin';
+import { css, CSSResult, html, LitElement } from 'lit';
+import { customElement, property, query, state } from 'lit/decorators.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-messageoftheday-banner': GrMessageOfTheDayBanner;
+  }
+}
+
+interface Message {
+  html?: string;
+  content_id?: string;
+}
+
+@customElement('gr-messageoftheday-banner')
+export class GrMessageOfTheDayBanner extends LitElement {
+  @query('#message')
+  messageElement?: HTMLDivElement;
+
+  @property({ type: Object })
+  plugin!: PluginApi;
+
+  @state()
+  private message?: Message;
+
+  @state()
+  private isHidden = true;
+
+  static override get styles() {
+    return [
+      window.Gerrit?.styles.font as CSSResult,
+      css`
+        #container {
+          background-color: var(--line-item-highlight-color);
+          display: flex;
+          height: fit-content;
+          justify-content: space-between;
+          align-items: center;
+          padding: 1em;
+        }
+        #message {
+          flex-grow: 1;
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.fetchMessage();
+  }
+
+  private async fetchMessage() {
+    this.plugin
+        .restApi()
+        .get<Message>(
+          `/config/server/${this.plugin.getPluginName()}~message`
+        )
+      .then(message => {
+        if (!message || !message.html) {
+          return;
+        }
+
+        this.message = message;
+        this.checkIsHidden();
+
+        // Wait for render then set innerHTML
+        this.updateComplete.then(() => {
+          if (this.messageElement && this.message?.html) {
+            this.messageElement.innerHTML = this.message.html;
+          }
+        })
+
+      })
+      .catch(error => {
+        console.error('Error fetching message:', error);
+      })
+  }
+
+  override render() {
+    if (this.isHidden) {
+      return html``;
+    }
+
+    return html`
+      <div id="container">
+        <div id="message"></div>
+        <gr-button id="dismissMessageBtn" link @click="${this.handleDismiss}">
+          Dismiss
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private handleDismiss() {
+    if (!this.message?.content_id) return;
+
+    document.cookie = `msg-${this.message.content_id}=1; path=/; expires=${this.getExpires()}`;
+    this.isHidden = true;
+  }
+
+  private checkIsHidden() {
+    if (!this.message?.content_id) {
+      this.isHidden = true;
+      return;
+    }
+    this.isHidden =
+      document.cookie.search(`msg-${this.message.content_id}=`) > -1;
+  }
+
+  private getExpires(): string {
+    const date = new Date();
+    date.setHours(0, 0, 0, 0);
+    date.setDate(date.getDate() + 1);
+    return date.toUTCString();
+  }
+}
diff --git a/web/gr-messageoftheday-banner_test.ts b/web/gr-messageoftheday-banner_test.ts
new file mode 100644
index 0000000..00da384
--- /dev/null
+++ b/web/gr-messageoftheday-banner_test.ts
@@ -0,0 +1,128 @@
+/**
+ * @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 './test/test-setup';
+import './gr-messageoftheday-banner';
+import {GrMessageOfTheDayBanner} from './gr-messageoftheday-banner';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {assert} from '@open-wc/testing';
+
+suite('gr-messageoftheday-banner tests', () => {
+  let element: GrMessageOfTheDayBanner;
+  let mockGetResponse: any;
+
+  function queryInShadow<T extends HTMLElement>(
+    el: HTMLElement,
+    selector: string
+  ): T | null {
+    return el.shadowRoot?.querySelector<T>(selector) || null;
+  }
+
+  async function createElement(): Promise<GrMessageOfTheDayBanner> {
+    const el = document.createElement(
+      'gr-messageoftheday-banner'
+    ) as GrMessageOfTheDayBanner;
+
+    // Set properties BEFORE adding to DOM
+    el.plugin = {
+      getPluginName: () => 'messageoftheday',
+      restApi: () => {
+        return {
+          get: async () => mockGetResponse,
+        };
+      },
+    } as unknown as PluginApi;
+
+    // Add to DOM
+    document.body.appendChild(el);
+
+    await el.updateComplete;
+    // Wait for async fetch
+    await new Promise(resolve => setTimeout(resolve, 50));
+    await el.updateComplete;
+    return el;
+  }
+
+  teardown(() => {
+    if (element && element.parentNode) {
+      element.parentNode.removeChild(element);
+    }
+    // Clear cookies
+    document.cookie = 'msg-test123=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
+  });
+
+  test('does not render when no message', async () => {
+    mockGetResponse = {};
+    element = await createElement();
+
+    const container = queryInShadow(element, '#container');
+    assert.isNull(container, 'container should not exist');
+  });
+
+  test('renders message when available', async () => {
+    mockGetResponse = {
+      html: '<p>Test message</p>',
+      content_id: 'test123',
+    };
+    element = await createElement();
+
+    const container = queryInShadow(element, '#container');
+    assert.isNotNull(container, 'container should exist');
+
+    const message = queryInShadow(element, '#message');
+    assert.isNotNull(message, 'message div should exist');
+    assert.include(message!.innerHTML, 'Test message');
+    
+    // Verify HTML is actually rendered (not escaped)
+    const paragraph = message!.querySelector('p');
+    assert.isNotNull(paragraph, 'HTML should be rendered as DOM elements');
+    assert.equal(paragraph!.textContent, 'Test message');
+  });
+
+  test('hides banner when dismiss button clicked', async () => {
+    mockGetResponse = {
+      html: '<p>Test message</p>',
+      content_id: 'test123',
+    };
+    element = await createElement();
+
+    let container = queryInShadow(element, '#container');
+    assert.isNotNull(container, 'container should exist initially');
+
+    const dismissBtn = queryInShadow<HTMLElement>(element, '#dismissMessageBtn');
+    assert.isNotNull(dismissBtn, 'dismiss button should exist');
+
+    dismissBtn!.click();
+    await element.updateComplete;
+
+    container = queryInShadow(element, '#container');
+    assert.isNull(container, 'container should be hidden after dismiss');
+  });
+
+  test('respects cookie to hide message', async () => {
+    // Set cookie before creating element
+    document.cookie = 'msg-test123=1; path=/';
+
+    mockGetResponse = {
+      html: '<p>Test message</p>',
+      content_id: 'test123',
+    };
+    element = await createElement();
+
+    const container = queryInShadow(element, '#container');
+    assert.isNull(container, 'container should not exist when cookie is set');
+  });
+});
diff --git a/web/gr-messageoftheday-edit.ts b/web/gr-messageoftheday-edit.ts
new file mode 100644
index 0000000..c7c6b99
--- /dev/null
+++ b/web/gr-messageoftheday-edit.ts
@@ -0,0 +1,320 @@
+/**
+ * @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 { PluginApi } from '@gerritcodereview/typescript-api/plugin';
+import { css, CSSResult, html, LitElement } from 'lit';
+import { customElement, property, query, state } from 'lit/decorators.js';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-messageoftheday-edit': GrMessageOfTheDayEdit;
+  }
+}
+
+interface MessageResponse {
+  html?: string;
+}
+
+interface CapabilitiesResponse {
+  'messageoftheday-updateBanner'?: boolean;
+}
+
+type ExpireUnit = 'm' | 'h' | 'd' | 'w';
+
+@customElement('gr-messageoftheday-edit')
+export class GrMessageOfTheDayEdit extends LitElement {
+  @query('#message_dialog_overlay')
+  dialogOverlay?: HTMLDialogElement;
+
+  @query('#message_dialog')
+  dialog?: HTMLElement;
+
+  @query('#messagePreview')
+  messagePreview?: HTMLDivElement;
+
+  @property({ type: Object })
+  plugin!: PluginApi;
+
+  @state()
+  private message = '';
+
+  @state()
+  private expireAfterValue = '';
+
+  @state()
+  private expireAfterUnit: ExpireUnit = 'd';
+
+  @state()
+  private canUpdate = false;
+
+  static override get styles() {
+    return [
+      window.Gerrit?.styles.font as CSSResult,
+      window.Gerrit?.styles.form as CSSResult,
+      window.Gerrit?.styles.modal as CSSResult,
+      css`
+        input,
+        select {
+          background-color: var(--select-background-color);
+          color: var(--primary-text-color);
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          padding: var(--spacing-s);
+          font: inherit;
+        }
+        gr-autogrow-textarea,
+        #messagePreview {
+          background-color: var(--view-background-color);
+          color: var(--primary-text-color);
+          font: inherit;
+          width: 80ch;
+          height: 25ch;
+          border: 1px solid var(--border-color);
+          border-radius: var(--border-radius);
+          box-sizing: border-box;
+        }
+        gr-autogrow-textarea:focus-within {
+          border: 2px solid var(--input-focus-border-color);
+        }
+        #messagePreview {
+          background-color: var(--background-color-tertiary);
+          overflow-y: auto;
+          padding: var(--spacing-s);
+        }
+        section {
+          margin-bottom: 1em;
+        }
+        .icon-button {
+          background: none;
+          box-shadow: none;
+          padding: 0;
+          min-width: 0;
+          border: none;
+          cursor: pointer;
+          color: var(--header-text-color);
+        }
+        .value {
+          display: flex;
+          flex-direction: column;
+          margin-bottom: 10px;
+        }
+        .value > * {
+          margin: 0;
+        }
+        .expire-inputs {
+          display: flex;
+          align-items: center;
+          gap: 5px;
+        }
+      `,
+    ];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.checkCanUpdate();
+  }
+
+  private async checkCanUpdate() {
+    this.plugin
+      .restApi()
+      .get<CapabilitiesResponse>(
+        '/accounts/self/capabilities?q=messageoftheday-updateBanner'
+      )
+      .then(response => {
+        if (response && response['messageoftheday-updateBanner'] === true) {
+          this.canUpdate = true;
+          this.fetchMessage();
+        }
+      })
+      .catch(error => {
+        console.error('Error checking updateBanner capability:', error);
+        this.canUpdate = false;
+      })
+  }
+
+  private async fetchMessage() {
+    this.plugin
+      .restApi()
+      .get<MessageResponse>('/config/server/messageoftheday~message')
+      .then(response => {
+        this.message = response?.html || '';
+      })
+      .catch(error => {
+        console.error('Error fetching message:', error);
+        this.message = '';
+      })
+  }
+
+  override render() {
+    if (!this.canUpdate) {
+      return html``;
+    }
+
+    return html`
+      <button class="icon-button" @click="${this.openDialog}">
+        📢
+      </button>
+      <dialog id="message_dialog_overlay" tabindex="-1">
+        <gr-dialog
+          id="message_dialog"
+          confirm-label="Save Message"
+          @confirm="${this.saveMessage}"
+          @cancel="${this.closeDialog}"
+        >
+          <div class="header" slot="header">Set Banner Message</div>
+          <div class="main" slot="main">
+            <section>
+              <span class="title">Expire After:</span>
+              <span class="value">
+                <div class="expire-inputs">
+                  <input
+                    id="expireAfterInput"
+                    type="number"
+                    placeholder="Enter Number"
+                    .value="${this.expireAfterValue}"
+                    @input="${this.handleExpireValueInput}"
+                  />
+                  <select
+                    id="expireAfterUnitSelect"
+                    .value="${this.expireAfterUnit}"
+                    @change="${this.handleExpireUnitChange}"
+                  >
+                    <option value="m">minutes</option>
+                    <option value="h">hours</option>
+                    <option value="d" ?selected="${this.expireAfterUnit === 'd'}">
+                      days
+                    </option>
+                    <option value="w">weeks</option>
+                  </select>
+                </div>
+              </span>
+            </section>
+            <section>
+              <span class="title">Message:</span>
+              <span class="value">
+                <gr-autogrow-textarea
+                  class="text_area"
+                  placeholder="Enter Message"
+                  autocomplete="off"
+                  .value="${this.message}"
+                  @input="${this.handleMessageInput}"
+                ></gr-autogrow-textarea>
+              </span>
+            </section>
+            <section>
+              <span class="title">Preview:</span>
+              <span class="value">
+                <div id="messagePreview" readonly></div>
+              </span>
+            </section>
+          </div>
+        </gr-dialog>
+      </dialog>
+    `;
+  }
+
+  override updated(changedProperties: Map<string, any>) {
+    if (changedProperties.has('message') && this.messagePreview) {
+      this.messagePreview.innerHTML = this.message;
+    }
+  }
+
+  override firstUpdated() {
+    // Set initial preview content if message exists
+    if (this.messagePreview && this.message) {
+      this.messagePreview.innerHTML = this.message;
+    }
+  }
+
+  private openDialog() {
+    this.dialogOverlay?.showModal();
+    this.dialog?.classList.toggle('invisible', false);
+  }
+
+  private closeDialog() {
+    this.dialog?.classList.toggle('invisible', true);
+    this.dialogOverlay?.close();
+  }
+
+  private handleMessageInput(e: CustomEvent) {
+    this.message = e.detail.value;
+  }
+
+  private handleExpireValueInput(e: Event) {
+    const target = e.target as HTMLInputElement;
+    this.expireAfterValue = target.value || '';
+  }
+
+  private handleExpireUnitChange(e: Event) {
+    const target = e.target as HTMLSelectElement;
+    this.expireAfterUnit = (target.value as ExpireUnit) || 'd';
+  }
+
+  private async saveMessage() {
+    const payload: { message: string; expires_at?: string } = {
+      message: this.message,
+    };
+
+    if (this.expireAfterValue) {
+      payload.expires_at = this.convertToFormattedDate(
+        this.expireAfterValue,
+        this.expireAfterUnit
+      );
+    }
+
+    try {
+      await this.plugin
+        .restApi()
+        .post('/config/server/messageoftheday~message', payload);
+      location.reload();
+    } catch (error) {
+      console.error('Error saving message:', error);
+    }
+  }
+
+  private convertToFormattedDate(value: string, unit: ExpireUnit): string {
+    const numValue = parseInt(value, 10);
+    let msToAdd = 0;
+
+    switch (unit) {
+      case 'm':
+        msToAdd = numValue * 60 * 1000;
+        break;
+      case 'h':
+        msToAdd = numValue * 60 * 60 * 1000;
+        break;
+      case 'd':
+        msToAdd = numValue * 24 * 60 * 60 * 1000;
+        break;
+      case 'w':
+        msToAdd = numValue * 7 * 24 * 60 * 60 * 1000;
+        break;
+    }
+
+    const now = new Date();
+    const future = new Date(now.getTime() + msToAdd);
+    const options: Intl.DateTimeFormatOptions = {
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+      hour: '2-digit',
+      minute: '2-digit',
+      timeZoneName: 'short',
+    };
+    return new Intl.DateTimeFormat('en-US', options).format(future);
+  }
+}
diff --git a/web/gr-messageoftheday-edit_test.ts b/web/gr-messageoftheday-edit_test.ts
new file mode 100644
index 0000000..48ba140
--- /dev/null
+++ b/web/gr-messageoftheday-edit_test.ts
@@ -0,0 +1,131 @@
+/**
+ * @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 './test/test-setup';
+import './gr-messageoftheday-edit';
+import {GrMessageOfTheDayEdit} from './gr-messageoftheday-edit';
+import {PluginApi} from '@gerritcodereview/typescript-api/plugin';
+import {assert} from '@open-wc/testing';
+
+suite('gr-messageoftheday-edit tests', () => {
+  let element: GrMessageOfTheDayEdit;
+  let mockCapabilities: any;
+  let mockMessage: any;
+
+  function queryInShadow<T extends HTMLElement>(
+    el: HTMLElement,
+    selector: string
+  ): T | null {
+    return el.shadowRoot?.querySelector<T>(selector) || null;
+  }
+
+  async function createElement(): Promise<GrMessageOfTheDayEdit> {
+    const el = document.createElement(
+      'gr-messageoftheday-edit'
+    ) as GrMessageOfTheDayEdit;
+
+    // Set properties BEFORE adding to DOM
+    el.plugin = {
+      restApi: () => {
+        return {
+          get: async (url: string) => {
+            if (url.includes('capabilities')) {
+              return mockCapabilities;
+            }
+            return mockMessage;
+          },
+          post: async () => ({}),
+        };
+      },
+    } as unknown as PluginApi;
+
+    // Add to DOM
+    document.body.appendChild(el);
+
+    await el.updateComplete;
+    // Wait for async capability check and message fetch
+    await new Promise(resolve => setTimeout(resolve, 100));
+    await el.updateComplete;
+    return el;
+  }
+
+  teardown(() => {
+    if (element && element.parentNode) {
+      element.parentNode.removeChild(element);
+    }
+  });
+
+  test('does not render when user lacks capability', async () => {
+    mockCapabilities = {};
+    mockMessage = {};
+
+    element = await createElement();
+
+    const button = queryInShadow(element, '.icon-button');
+    assert.isNull(button, 'button should not exist without capability');
+  });
+
+  test('renders button when user has capability', async () => {
+    mockCapabilities = {'messageoftheday-updateBanner': true};
+    mockMessage = {html: 'Existing message'};
+
+    element = await createElement();
+
+    const button = queryInShadow(element, '.icon-button');
+    assert.isNotNull(button, 'button should exist with capability');
+  });
+
+  test('preview shows HTML content when message is edited', async () => {
+    mockCapabilities = {'messageoftheday-updateBanner': true};
+    mockMessage = {html: '<b>Bold text</b>'};
+
+    element = await createElement();
+
+    // Open dialog
+    const button = queryInShadow<HTMLElement>(element, '.icon-button');
+    button!.click();
+    await element.updateComplete;
+
+    const preview = queryInShadow<HTMLDivElement>(element, '#messagePreview');
+    assert.isNotNull(preview, 'preview should exist');
+
+    // Check that initial HTML is rendered
+    assert.include(preview!.innerHTML, '<b>Bold text</b>');
+
+    // Verify HTML is actually rendered as DOM
+    const bold = preview!.querySelector('b');
+    assert.isNotNull(bold, 'HTML should be rendered as DOM elements');
+    assert.equal(bold!.textContent, 'Bold text');
+
+    // Simulate typing in textarea
+    const textarea = queryInShadow(element, 'gr-autogrow-textarea');
+    assert.isNotNull(textarea, 'textarea should exist');
+
+    // Simulate input event (like user typing)
+    textarea!.dispatchEvent(new CustomEvent('input', {
+      detail: {value: '<p>New <em>message</em></p>'},
+      bubbles: true,
+      composed: true
+    }));
+    await element.updateComplete;
+
+    // Check preview updates
+    assert.include(preview!.innerHTML, '<em>message</em>');
+    const em = preview!.querySelector('em');
+    assert.isNotNull(em, 'Updated HTML should render');
+    assert.equal(em!.textContent, 'message');
+  });
+});
diff --git a/gr-messageoftheday/plugin.js b/web/plugin.ts
similarity index 76%
rename from gr-messageoftheday/plugin.js
rename to web/plugin.ts
index 475e28d..635e850 100644
--- a/gr-messageoftheday/plugin.js
+++ b/web/plugin.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2021 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,11 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '@gerritcodereview/typescript-api/gerrit';
+import './gr-messageoftheday-banner';
+import './gr-messageoftheday-edit';
 
-import './gr-messageoftheday-banner.js';
-import './gr-messageoftheday-edit.js';
-
-Gerrit.install(plugin => {
+window.Gerrit?.install(plugin => {
   plugin.registerCustomComponent('header-top-right', 'gr-messageoftheday-edit');
   plugin.registerCustomComponent('banner', 'gr-messageoftheday-banner');
 });
diff --git a/gr-messageoftheday/plugin.js b/web/test/test-setup.ts
similarity index 60%
copy from gr-messageoftheday/plugin.js
copy to web/test/test-setup.ts
index 475e28d..788b977 100644
--- a/gr-messageoftheday/plugin.js
+++ b/web/test/test-setup.ts
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2021 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,11 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '@gerritcodereview/typescript-api/gerrit';
+import {css} from 'lit';
 
-import './gr-messageoftheday-banner.js';
-import './gr-messageoftheday-edit.js';
-
-Gerrit.install(plugin => {
-  plugin.registerCustomComponent('header-top-right', 'gr-messageoftheday-edit');
-  plugin.registerCustomComponent('banner', 'gr-messageoftheday-banner');
-});
+// Setup window.Gerrit mock
+window.Gerrit = {
+  install: () => {},
+  styles: {
+    font: css``,
+    form: css``,
+    material: css``,
+    menuPage: css``,
+    spinner: css``,
+    subPage: css``,
+    table: css``,
+    modal: css``,
+  },
+};
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..65940e1
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig-plugins-base.json",
+  "compilerOptions": {
+    "rootDir": "."
+  },
+  "include": [
+    "**/*.ts"
+  ]
+}
diff --git a/web/web_test_runner.sh b/web/web_test_runner.sh
new file mode 100755
index 0000000..e1a3bfb
--- /dev/null
+++ b/web/web_test_runner.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+
+set -euo pipefail
+# Get the absolute path to the runfiles directory
+RUNFILES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+./$1 --config $2 \
+  --root-dir "${RUNFILES_DIR}" \
+  --dir 'plugins/messageoftheday/web/_bazel_ts_out_tests' \
+  --test-files 'plugins/messageoftheday/web/_bazel_ts_out_tests/*_test.js' \
+  --ts-config="plugins/messageoftheday/web/tsconfig.json"