diff --git a/web_src/js/components/ActionStatusIcon.vue b/web_src/js/components/ActionStatusIcon.vue
index 12da669ac4d..bebb510467b 100644
--- a/web_src/js/components/ActionStatusIcon.vue
+++ b/web_src/js/components/ActionStatusIcon.vue
@@ -2,32 +2,33 @@
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, cancelling, unknown.
-->
-
-
-
-
-
-
-
-
+
diff --git a/web_src/js/components/RepoActionView.vue b/web_src/js/components/RepoActionView.vue
index 03d62bd7ccf..9ce926673b9 100644
--- a/web_src/js/components/RepoActionView.vue
+++ b/web_src/js/components/RepoActionView.vue
@@ -1,7 +1,8 @@
diff --git a/web_src/js/modules/action-status-icon.test.ts b/web_src/js/modules/action-status-icon.test.ts
new file mode 100644
index 00000000000..0729088acbd
--- /dev/null
+++ b/web_src/js/modules/action-status-icon.test.ts
@@ -0,0 +1,9 @@
+import {getActionStatusIcon} from './action-status-icon.ts';
+
+test('getActionStatusIcon', () => {
+ expect(getActionStatusIcon('success')).toEqual({name: 'octicon-check', colorClass: 'tw-text-green'});
+ expect(getActionStatusIcon('success', 'circle-fill')).toEqual({name: 'octicon-check-circle-fill', colorClass: 'tw-text-green'});
+ expect(getActionStatusIcon('running')).toEqual({name: 'gitea-running', colorClass: 'tw-text-yellow'});
+ expect(getActionStatusIcon('failure', 'circle-fill')).toEqual({name: 'octicon-x-circle-fill', colorClass: 'tw-text-red'});
+ expect(getActionStatusIcon('cancelled')).toEqual({name: 'octicon-stop', colorClass: 'tw-text-text-light'});
+});
diff --git a/web_src/js/modules/action-status-icon.ts b/web_src/js/modules/action-status-icon.ts
new file mode 100644
index 00000000000..8148c98192f
--- /dev/null
+++ b/web_src/js/modules/action-status-icon.ts
@@ -0,0 +1,37 @@
+import type {SvgName} from '../svg.ts';
+import type {ActionsStatus} from './gitea-actions.ts';
+
+export type ActionStatusIconVariant = 'circle-fill' | '';
+
+export type ActionStatusIconSpec = {
+ name: SvgName,
+ colorClass: string,
+};
+
+// Keep in sync with templates/repo/icons/action_status.tmpl and ActionStatusIcon.vue.
+export function getActionStatusIcon(status: ActionsStatus, iconVariant: ActionStatusIconVariant = ''): ActionStatusIconSpec {
+ const circleFill = iconVariant === 'circle-fill';
+ switch (status) {
+ case 'success':
+ return {name: circleFill ? 'octicon-check-circle-fill' : 'octicon-check', colorClass: 'tw-text-green'};
+ case 'skipped':
+ return {name: 'octicon-skip', colorClass: 'tw-text-text-light'};
+ case 'cancelled':
+ return {name: 'octicon-stop', colorClass: 'tw-text-text-light'};
+ case 'waiting':
+ return {name: 'octicon-circle', colorClass: 'tw-text-text-light'};
+ case 'blocked':
+ return {name: 'octicon-blocked', colorClass: 'tw-text-yellow'};
+ case 'running':
+ return {name: 'gitea-running', colorClass: 'tw-text-yellow'};
+ case 'cancelling':
+ return {name: 'octicon-stop', colorClass: 'tw-text-yellow'};
+ case 'failure':
+ case 'unknown':
+ return {name: circleFill ? 'octicon-x-circle-fill' : 'octicon-x', colorClass: 'tw-text-red'};
+ default: {
+ const _exhaustive: never = status;
+ return _exhaustive;
+ }
+ }
+}
diff --git a/web_src/js/modules/favicon-status.test.ts b/web_src/js/modules/favicon-status.test.ts
new file mode 100644
index 00000000000..df048559253
--- /dev/null
+++ b/web_src/js/modules/favicon-status.test.ts
@@ -0,0 +1,29 @@
+import {buildStatusFaviconSvg, resetActionFavicon, syncActionRunFavicon} from './favicon-status.ts';
+
+test('buildStatusFaviconSvg uses action status icons', () => {
+ const success = buildStatusFaviconSvg('success');
+ expect(success).toContain('viewBox="0 0 640 640"');
+ expect(success).toContain('fill:#609926');
+ expect(success).toContain('data-actions-status-name="success"');
+
+ const running = buildStatusFaviconSvg('running');
+ expect(running).toContain('data-actions-status-name="running"');
+
+ const failure = buildStatusFaviconSvg('failure');
+ expect(failure).toContain('data-actions-status-name="failure"');
+});
+
+test('syncActionRunFavicon updates favicon links', () => {
+ document.head.innerHTML = `
+
+
+ `;
+ const links = Array.from(document.querySelectorAll('link[rel~="icon"]'));
+ syncActionRunFavicon('running');
+ for (const link of links) {
+ expect(link.href).toMatch(/^data:image\/svg\+xml,/);
+ expect(decodeURIComponent(link.href)).toContain('data-actions-status-name="running"');
+ }
+ resetActionFavicon();
+ expect(links[0].href).toContain('favicon.svg');
+});
diff --git a/web_src/js/modules/favicon-status.ts b/web_src/js/modules/favicon-status.ts
new file mode 100644
index 00000000000..6667a08e653
--- /dev/null
+++ b/web_src/js/modules/favicon-status.ts
@@ -0,0 +1,90 @@
+import {getActionStatusIcon} from './action-status-icon.ts';
+import type {ActionsStatus} from './gitea-actions.ts';
+import {svgParseOuterInner} from '../svg.ts';
+import {html, htmlRaw} from '../utils/html.ts';
+
+const {svgOuter, svgInnerHtml: giteaFaviconInner} = svgParseOuterInner('gitea-favicon');
+const faviconViewBox = svgOuter.getAttribute('viewBox')!;
+const [, , faviconViewBoxWidth, faviconViewBoxHeight] = faviconViewBox.split(/\s+/).map(Number);
+
+// the status badge is rendered in the bottom-right corner, following GitHub Actions favicon proportions
+const badgeIconSize = 16;
+const badgeSizeRatio = 340 / 640;
+const badgeMargin = 6;
+const badgeDrawSize = faviconViewBoxWidth * badgeSizeRatio;
+const badgeX = faviconViewBoxWidth - badgeDrawSize - badgeMargin;
+const badgeY = faviconViewBoxHeight - badgeDrawSize - badgeMargin;
+const badgeScale = badgeDrawSize / badgeIconSize;
+// white ring behind the badge so it stands out from the logo, like GitHub's favicon
+const badgeCenter = badgeDrawSize / 2;
+const badgeRingRadius = badgeCenter + badgeDrawSize * 0.08;
+
+let currentStatus: ActionsStatus | null = null;
+const defaultFaviconHrefs = new Map();
+const faviconDataUrlCache = new Map();
+let colorProbe: HTMLElement | null = null;
+
+function rememberDefaultFaviconHrefs() {
+ if (defaultFaviconHrefs.size > 0) return;
+ for (const link of document.querySelectorAll('link[rel~="icon"]')) {
+ defaultFaviconHrefs.set(link, link.href);
+ }
+}
+
+function resolveTailwindTextColor(colorClass: string): string {
+ if (!colorProbe) {
+ colorProbe = document.createElement('span');
+ colorProbe.style.display = 'none';
+ document.body.append(colorProbe);
+ }
+ colorProbe.className = colorClass;
+ return getComputedStyle(colorProbe).color || '#000000';
+}
+
+function buildStatusIconMarkup(status: ActionsStatus): string {
+ const {name, colorClass} = getActionStatusIcon(status, 'circle-fill');
+ const color = resolveTailwindTextColor(colorClass);
+ const {svgInnerHtml} = svgParseOuterInner(name);
+ const coloredInner = svgInnerHtml.replaceAll('currentColor', color);
+ const ring = html``;
+ const badge = html`${htmlRaw(coloredInner)}`;
+ return html`${htmlRaw(ring)}${htmlRaw(badge)}`;
+}
+
+export function buildStatusFaviconSvg(status: ActionsStatus): string {
+ return html``;
+}
+
+function buildStatusFaviconDataUrl(status: ActionsStatus): string {
+ const cached = faviconDataUrlCache.get(status);
+ if (cached) return cached;
+ const dataUrl = `data:image/svg+xml,${encodeURIComponent(buildStatusFaviconSvg(status))}`;
+ faviconDataUrlCache.set(status, dataUrl);
+ return dataUrl;
+}
+
+function setFaviconHref(href: string) {
+ rememberDefaultFaviconHrefs();
+ for (const link of defaultFaviconHrefs.keys()) {
+ if (link.isConnected) link.href = href;
+ }
+}
+
+export function syncActionRunFavicon(status: ActionsStatus | ''): void {
+ if (status === '') {
+ resetActionFavicon();
+ return;
+ }
+ if (status === currentStatus) return;
+ setFaviconHref(buildStatusFaviconDataUrl(status));
+ currentStatus = status;
+}
+
+export function resetActionFavicon(): void {
+ if (currentStatus === null) return;
+ rememberDefaultFaviconHrefs();
+ for (const [link, href] of defaultFaviconHrefs) {
+ if (link.isConnected) link.href = href;
+ }
+ currentStatus = null;
+}
diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts
index 4495ec1e5a9..fe30092d102 100644
--- a/web_src/js/svg.ts
+++ b/web_src/js/svg.ts
@@ -5,6 +5,7 @@ import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-che
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
import giteaExclamation from '../../public/assets/img/svg/gitea-exclamation.svg';
+import giteaFavicon from '../../public/assets/img/favicon.svg';
import giteaRunning from '../../public/assets/img/svg/gitea-running.svg';
import octiconArchive from '../../public/assets/img/svg/octicon-archive.svg';
import octiconArrowLeft from '../../public/assets/img/svg/octicon-arrow-left.svg';
@@ -93,6 +94,7 @@ const svgs = {
'gitea-double-chevron-right': giteaDoubleChevronRight,
'gitea-empty-checkbox': giteaEmptyCheckbox,
'gitea-exclamation': giteaExclamation,
+ 'gitea-favicon': giteaFavicon,
'gitea-running': giteaRunning,
'octicon-archive': octiconArchive,
'octicon-arrow-left': octiconArrowLeft,