From 7feeb24480f9d45f4f4a732f2fb62024ebca94fd Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 29 Oct 2026 07:35:15 +0300 Subject: [PATCH] ogre,web/avatar: draw the avatar gradient behind card avatars and drop the diff chip when a pull has no stats Signed-off-by: dawn --- ogre/src/__tests__/get.test.ts | 21 ++++++++- ogre/src/__tests__/render.test.ts | 51 +++++++++++++++++++++- ogre/src/components/cards/pull-request.tsx | 12 ++--- ogre/src/components/shared/avatar.tsx | 35 ++++++++++----- ogre/src/validation.ts | 6 +-- web/src/lib/avatarGradient.test.ts | 2 +- web/src/lib/avatarGradient.ts | 10 +++-- 7 files changed, 112 insertions(+), 25 deletions(-) diff --git a/ogre/src/__tests__/get.test.ts b/ogre/src/__tests__/get.test.ts index 6fe88e16a..6be92cd93 100644 --- a/ogre/src/__tests__/get.test.ts +++ b/ogre/src/__tests__/get.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "bun:test"; import worker, { decodePayload } from "../index"; -import { formatAvatarUrl } from "../components/shared/avatar"; +import { avatarDid, formatAvatarUrl } from "../components/shared/avatar"; describe("worker fetch and GET payload decoding", () => { it("decodes JSON payload directly", () => { @@ -58,6 +58,25 @@ describe("worker fetch and GET payload decoding", () => { expect(formatAvatarUrl(undefined)).toBeUndefined(); }); + it("reads the did out of tangled avatar URLs", () => { + expect( + avatarDid("https://tangled.org/avatar/did%3Aplc%3A123?size=256&format=jpeg"), + ).toBe("did:plc:123"); + expect(avatarDid("https://next.tangled.org/avatar/did%3Aweb%3Aa.b?size=256")).toBe( + "did:web:a.b", + ); + expect(avatarDid("https://avatar.tangled.sh/sig/did:plc:123?size=256")).toBe( + "did:plc:123", + ); + expect(avatarDid("https://avatar-dev.tangled.org/sig/did:plc:123")).toBe( + "did:plc:123", + ); + expect(avatarDid("https://example.com/did:plc:123")).toBeUndefined(); + expect(avatarDid("https://tangled.org/avatar/")).toBeUndefined(); + expect(avatarDid("data:image/jpeg;base64,123")).toBeUndefined(); + expect(avatarDid(undefined)).toBeUndefined(); + }); + it("accepts payloads without optional avatarUrl or createdAt", async () => { const payload = { type: "repository", diff --git a/ogre/src/__tests__/render.test.ts b/ogre/src/__tests__/render.test.ts index df7b0af2e..022bfb941 100644 --- a/ogre/src/__tests__/render.test.ts +++ b/ogre/src/__tests__/render.test.ts @@ -1,4 +1,4 @@ -import { test, describe, beforeAll } from "bun:test"; +import { test, describe, beforeAll, expect, spyOn } from "bun:test"; import { writeFileSync, mkdirSync, readFileSync } from "fs"; import { join } from "path"; import { h, type VNode } from "preact"; @@ -12,6 +12,8 @@ import { CommitCard } from "../components/cards/commit"; import { FileCard } from "../components/cards/file"; import { TagCard } from "../components/cards/tag"; import { PipelineCard } from "../components/cards/pipeline"; +import { Avatar } from "../components/shared/avatar"; +import { COLORS } from "../components/shared/constants"; import { repositoryCardSchema, issueCardSchema, @@ -69,6 +71,7 @@ const renderAndSave = async

(component: VNode

, filename: string) => { const { svg, png } = await renderCard(component as VNode); saveSvg(filename.replace(".png", ".svg"), svg); savePng(filename, png); + return svg; }; describe("repository card", () => { @@ -153,6 +156,29 @@ describe("pull request cards", () => { ); }); + test("renders pull request without diff stats", async () => { + const { filesChanged, additions, deletions, ...data } = + createPullRequestData(avatarDataUri, { rounds: 1 }); + const validated = pullRequestCardSchema.parse(data); + expect(validated.filesChanged).toBeUndefined(); + const svg = await renderAndSave( + h(PullRequestCard, validated), + "pull-request-card-no-diff.png", + ); + expect(svg).not.toContain(COLORS.diff.additions.text); + }); + + test("renders pull request with diff stats", async () => { + const validated = pullRequestCardSchema.parse( + createPullRequestData(avatarDataUri), + ); + const svg = await renderAndSave( + h(PullRequestCard, validated), + "pull-request-card-diff.png", + ); + expect(svg).toContain(COLORS.diff.additions.text); + }); + test("renders pull request with long title", async () => { const data = createLongTitlePullRequestData(avatarDataUri, { pullRequestNumber: 42, @@ -176,6 +202,29 @@ describe("pull request cards", () => { }); }); +describe("avatar", () => { + test("shows the did's gradient when the image fails to load", async () => { + const fetch = spyOn(globalThis, "fetch").mockResolvedValue( + new Response("blob not found", { status: 400 }), + ); + const svg = await renderAndSave( + h(Avatar, { + src: "https://avatar.tangled.sh/sig/did:plc:3rwz3xfw2crswgifqgc3g7zh?size=256", + size: 120, + }), + "avatar-failed.png", + ).finally(() => fetch.mockRestore()); + expect(svg).toContain(" { + const svg = await renderAndSave(h(Avatar, { size: 120 }), "avatar-none.png"); + expect(svg).not.toContain("Gradient"); + }); +}); + describe("profile cards", () => { test("renders profile", async () => { const validated = profileCardSchema.parse(createProfileData(avatarDataUri)); diff --git a/ogre/src/components/cards/pull-request.tsx b/ogre/src/components/cards/pull-request.tsx index c82366766..5dee5f21a 100644 --- a/ogre/src/components/cards/pull-request.tsx +++ b/ogre/src/components/cards/pull-request.tsx @@ -85,11 +85,13 @@ export function PullRequestCard(data: PullRequestCardData) { - + {data.filesChanged !== undefined ? ( + + ) : null} {data.rounds} {data.rounds <= 1 ? "round" : "rounds"} diff --git a/ogre/src/components/shared/avatar.tsx b/ogre/src/components/shared/avatar.tsx index 549dc4cce..3408758af 100644 --- a/ogre/src/components/shared/avatar.tsx +++ b/ogre/src/components/shared/avatar.tsx @@ -1,19 +1,21 @@ +import { gradientCss } from "../../../../web/src/lib/avatarGradient"; import { COLORS } from "./constants"; +const isTangledAvatar = (url: URL): boolean => + url.hostname === "avatar.tangled.sh" || + url.hostname === "avatar-dev.tangled.org" || + ((url.hostname === "tangled.org" || + url.hostname.endsWith(".tangled.org") || + url.hostname.endsWith(".tangled.sh") || + url.hostname === "localhost" || + url.hostname === "127.0.0.1") && + url.pathname.startsWith("/avatar/")); + export function formatAvatarUrl(src?: string): string | undefined { if (!src || src.startsWith("data:")) return src; try { const url = new URL(src); - const isTangledAvatar = - url.hostname === "avatar.tangled.sh" || - url.hostname === "avatar-dev.tangled.org" || - ((url.hostname.endsWith(".tangled.org") || - url.hostname.endsWith(".tangled.sh") || - url.hostname === "localhost" || - url.hostname === "127.0.0.1") && - url.pathname.startsWith("/avatar/")); - - if (isTangledAvatar || url.searchParams.get("format") === "webp") { + if (isTangledAvatar(url) || url.searchParams.get("format") === "webp") { url.searchParams.set("format", "jpeg"); return url.toString(); } @@ -21,6 +23,17 @@ export function formatAvatarUrl(src?: string): string | undefined { return src; } +// the did seeds the same gradient the web app draws under an avatar +export function avatarDid(src?: string): string | undefined { + if (!src) return undefined; + try { + const url = new URL(src); + const did = decodeURIComponent(url.pathname.split("/").pop() ?? ""); + if (isTangledAvatar(url) && did.startsWith("did:")) return did; + } catch {} + return undefined; +} + interface AvatarProps { src?: string; size?: number; @@ -28,6 +41,7 @@ interface AvatarProps { export function Avatar({ src, size = 64 }: AvatarProps) { const avatarSrc = formatAvatarUrl(src); + const did = avatarDid(src); const ring = Math.max(2, Math.round(size / 32)); const inner = size - ring * 2; @@ -50,6 +64,7 @@ export function Avatar({ src, size = 64 }: AvatarProps) { overflow: "hidden", display: "flex", backgroundColor: COLORS.surface, + ...(did && { backgroundImage: gradientCss(did) }), }}> {avatarSrc ? ( { describe("gradientCss", () => { it.each(dids)("is a radial glow over a linear gradient for %s", (did) => { expect(gradientCss(did)).toMatch( - /^radial-gradient\(\d+(\.\d+)?% \d+(\.\d+)?% at [\d.]+% [\d.]+%, rgb\(\d+ \d+ \d+ \/ [\d.]+\), rgb\(\d+ \d+ \d+ \/ 0\)\), linear-gradient\(\d+deg, (#[0-9a-f]{6} -?[\d.]+%(, )?)+\)$/ + /^radial-gradient\(\d+(\.\d+)?% \d+(\.\d+)?% at [\d.]+% [\d.]+%, #[0-9a-f]{8}, #[0-9a-f]{6}00\), linear-gradient\(\d+deg, (#[0-9a-f]{6} -?[\d.]+%(, )?)+\)$/ ); }); diff --git a/web/src/lib/avatarGradient.ts b/web/src/lib/avatarGradient.ts index dc08d236c..89671c82e 100644 --- a/web/src/lib/avatarGradient.ts +++ b/web/src/lib/avatarGradient.ts @@ -59,8 +59,11 @@ export const gradientFor = (seed: string): Gradient => { return { angle, stops, glow }; }; -const rgb = (hex: string) => - [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)).join(" "); +// ogre's satori reads rgb(r g b / 0) as opaque black but handles hex alpha +const alpha = (opacity: number) => + Math.round(opacity * 255) + .toString(16) + .padStart(2, "0"); // the same picture as the worker's svg, as a css background-image export const gradientCss = (seed: string): string => { @@ -72,10 +75,9 @@ export const gradientCss = (seed: string): string => { .map((s) => `${s.color} ${round(50 + (s.offset - 50) * scale)}%`) .join(", "); const radius = round(glow.r * 100); - const color = rgb(glow.color); return ( `radial-gradient(${radius}% ${radius}% at ${round(glow.x * 100)}% ${round(glow.y * 100)}%, ` + - `rgb(${color} / ${glow.opacity}), rgb(${color} / 0)), ` + + `${glow.color}${alpha(glow.opacity)}, ${glow.color}00), ` + `linear-gradient(${(angle + 135) % 360}deg, ${linear})` ); }; -- knotmirror 0.0.1