diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx
index 728626271f..fdd6981293 100644
--- a/apps/webapp/app/components/primitives/Avatar.tsx
+++ b/apps/webapp/app/components/primitives/Avatar.tsx
@@ -9,6 +9,7 @@ import {
StarIcon,
} from "@heroicons/react/20/solid";
import type { Prisma } from "@trigger.dev/database";
+import { useCallback, useState } from "react";
import { z } from "zod";
import { cn } from "~/utils/cn";
@@ -76,7 +77,7 @@ export function Avatar({
/>
);
case "image":
- return ;
+ return ;
}
}
@@ -190,20 +191,36 @@ function AvatarIcon({
}
function AvatarImage({ avatar, size }: { avatar: ImageAvatar; size: number }) {
- if (!avatar.url) {
+ const [failed, setFailed] = useState(false);
+
+ // A server-rendered image can finish failing before hydration, so onError never fires.
+ const detectFailedLoad = useCallback((node: HTMLImageElement | null) => {
+ if (node && node.complete && node.naturalWidth === 0) {
+ setFailed(true);
+ }
+ }, []);
+
+ if (!avatar.url || failed) {
return (
-
+
);
}
return (
-
+
setFailed(true)}
/>
);
diff --git a/apps/webapp/app/components/primitives/RadioButton.tsx b/apps/webapp/app/components/primitives/RadioButton.tsx
index d936a81b92..8a0a89eae7 100644
--- a/apps/webapp/app/components/primitives/RadioButton.tsx
+++ b/apps/webapp/app/components/primitives/RadioButton.tsx
@@ -22,7 +22,7 @@ const variants = {
},
"button/small": {
button:
- "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 data-disabled:hover:bg-secondary hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4",
+ "flex items-center w-fit h-8 pl-2 pr-3 rounded-md border border-border-bright/50 shadow-xs bg-background-bright transition hover:bg-surface-hover-subtle data-disabled:opacity-70 data-disabled:hover:bg-background-bright hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected",
label: "text-sm text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-0",
@@ -30,7 +30,7 @@ const variants = {
},
button: {
button:
- "w-fit py-2 pl-3 pr-4 rounded border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-[state=checked]:bg-background-dimmed data-disabled:opacity-70",
+ "w-fit py-2 pl-3 pr-4 rounded border border-border-bright/50 shadow-xs bg-background-bright hover:bg-surface-hover-subtle transition hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected data-disabled:opacity-70",
label: "text-text-bright select-none",
description: "text-text-dimmed",
inputPosition: "mt-1",
@@ -38,7 +38,7 @@ const variants = {
},
description: {
button:
- "w-full p-2.5 rounded-md border border-border-bright/50 shadow-xs bg-secondary transition hover:bg-background-raised data-disabled:opacity-70 hover:data-[state=checked]:bg-text-bright/4 data-[state=checked]:bg-text-bright/4",
+ "w-full p-2.5 rounded-md border border-border-bright/50 shadow-xs bg-background-bright transition hover:bg-surface-hover-subtle data-disabled:opacity-70 hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected",
label: "text-text-bright font-semibold -mt-0.5 text-left text-sm",
description: "text-text-dimmed mt-0 text-left",
inputPosition: "mt-0",
@@ -46,7 +46,7 @@ const variants = {
},
icon: {
button:
- "w-full p-2.5 pb-4 rounded-sm border border-border-bright/50 shadow-xs bg-secondary hover:bg-background-raised transition data-disabled:opacity-70 data-[state=checked]:bg-background-dimmed",
+ "w-full p-2.5 pb-4 rounded-sm border border-border-bright/50 shadow-xs bg-background-bright hover:bg-surface-hover-subtle transition data-disabled:opacity-70 hover:data-[state=checked]:bg-surface-selected-hover data-[state=checked]:border-border-selected data-[state=checked]:bg-surface-selected",
label: "text-text-bright font-semibold -mt-1 text-left",
description: "text-text-dimmed mt-0 text-left",
inputPosition: "mt-0",
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx
index f1f267425c..c27eb15497 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.apikeys/route.tsx
@@ -1028,7 +1028,7 @@ function PresetOptions({
id={`api-key-access-${preset.id}`}
value={preset.id}
variant="description"
- className="h-full min-h-[3.5rem] items-start border-grid-bright bg-background-bright p-3 shadow-none [&_p]:mt-0.5 [&_p]:text-xs [&_p]:leading-snug hover:border-border-bright hover:bg-background-hover data-[state=checked]:border-indigo-500/70 data-[state=checked]:bg-indigo-500/10"
+ className="h-full min-h-[3.5rem] items-start border-grid-bright bg-background-bright p-3 shadow-none [&_p]:mt-0.5 [&_p]:text-xs [&_p]:leading-snug hover:border-border-bright hover:bg-background-hover data-[state=checked]:border-indigo-500/70 data-[state=checked]:bg-indigo-500/10 hover:data-[state=checked]:bg-indigo-500/15"
label={
preset.id === "FULL_ACCESS" ? (
diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css
index e9955747a8..19f3f5b7d6 100644
--- a/apps/webapp/app/tailwind.css
+++ b/apps/webapp/app/tailwind.css
@@ -154,7 +154,10 @@
--color-background-dimmed: var(--color-charcoal-850);
--color-background-bright: var(--color-charcoal-800);
--color-background-hover: var(--color-charcoal-750);
+ --color-surface-hover-subtle: var(--color-charcoal-750);
--color-background-raised: var(--color-charcoal-700);
+ --color-surface-selected: var(--color-charcoal-650);
+ --color-surface-selected-hover: var(--color-charcoal-600);
--color-surface-control: var(--color-charcoal-600);
--color-surface-control-hover: var(--color-charcoal-550);
--color-surface-control-active: var(--color-charcoal-500);
@@ -163,6 +166,9 @@
/* Borders, from subtlest to most visible */
--color-grid-dimmed: var(--color-charcoal-750);
--color-grid-bright: var(--color-charcoal-700);
+ /* Blend of border-bright over the selected card, so it tracks whatever ramp
+ border-bright is on - the dark themes need no contrast entry of their own */
+ --color-border-selected: color-mix(in srgb, var(--color-border-bright) 50%, var(--color-surface-selected));
--color-border-bright: var(--color-charcoal-600);
--color-border-brighter: var(--color-charcoal-550);
--color-border-brightest: var(--color-charcoal-500);
@@ -766,14 +772,21 @@
--color-background-dimmed: #fbfbfc;
--color-background-bright: #ffffff;
--color-background-hover: #f2f3f5;
+ /* Cards on white need a gentler hover than the app-wide surfaces */
+ --color-surface-hover-subtle: #f7f8f9;
--color-background-raised: #e9eaee;
--color-surface-control: #dcdee3;
--color-surface-control-hover: #cfd2d9;
--color-surface-control-active: #b8bcc6;
+ /* Selection sits lighter than the controls here - on white a soft grey is
+ already enough to read as selected */
+ --color-surface-selected: #eff0f2;
+ --color-surface-selected-hover: #e7e9ec;
/* Borders */
--color-grid-dimmed: #eceef1;
--color-grid-bright: #e2e4e9;
+ --color-border-selected: #e0e2e6;
--color-border-bright: #d2d5db;
--color-border-brighter: #b9bdc7;
--color-border-brightest: #9ba1ad;
@@ -931,6 +944,7 @@
--color-background-dimmed: color-mix(in srgb, var(--color-charcoal-850), #000 calc(var(--theme-contrast, 0) * 55%));
--color-background-bright: color-mix(in srgb, var(--color-charcoal-800), #000 calc(var(--theme-contrast, 0) * 45%));
--color-background-hover: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%));
+ --color-surface-hover-subtle: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%));
--color-background-raised: color-mix(in srgb, var(--color-charcoal-700), #000 calc(var(--theme-contrast, 0) * 25%));
--color-input-bg: color-mix(in srgb, var(--color-charcoal-750), #000 calc(var(--theme-contrast, 0) * 35%));
@@ -955,6 +969,9 @@
page-behind surfaces darken a touch so panels separate */
--color-background-deep: color-mix(in srgb, #f1f2f4, #000 calc(var(--theme-contrast, 0) * 10%));
--color-background-hover: color-mix(in srgb, #f2f3f5, #000 calc(var(--theme-contrast, 0) * 8%));
+ /* Radio card surfaces take no ramp at all on white - their contrast is
+ carried by border-selected below. Deliberately absent: surface-hover-subtle,
+ surface-selected, surface-selected-hover. */
--color-background-raised: color-mix(in srgb, #e9eaee, #000 calc(var(--theme-contrast, 0) * 10%));
/* Controls and borders push hard toward black - this is where light-mode
@@ -964,6 +981,8 @@
--color-surface-control-active: color-mix(in srgb, #b8bcc6, #000 calc(var(--theme-contrast, 0) * 32%));
--color-grid-dimmed: color-mix(in srgb, #eceef1, #000 calc(var(--theme-contrast, 0) * 28%));
--color-grid-bright: color-mix(in srgb, #e2e4e9, #000 calc(var(--theme-contrast, 0) * 32%));
+ /* Steeper than border-bright so the selected card stays the loudest edge */
+ --color-border-selected: color-mix(in srgb, #e0e2e6, #000 calc(var(--theme-contrast, 0) * 45%));
--color-border-bright: color-mix(in srgb, #d2d5db, #000 calc(var(--theme-contrast, 0) * 38%));
--color-border-brighter: color-mix(in srgb, #b9bdc7, #000 calc(var(--theme-contrast, 0) * 42%));
--color-border-brightest: color-mix(in srgb, #9ba1ad, #000 calc(var(--theme-contrast, 0) * 46%));
diff --git a/apps/webapp/app/utils/cspImageOrigins.test.ts b/apps/webapp/app/utils/cspImageOrigins.test.ts
index 0ad1e34697..c08c6cde4b 100644
--- a/apps/webapp/app/utils/cspImageOrigins.test.ts
+++ b/apps/webapp/app/utils/cspImageOrigins.test.ts
@@ -17,7 +17,11 @@ function directivePermits(directive: string, imageUrl: string): boolean {
if (!source.startsWith("http")) return false;
const parsed = new URL(source);
if (parsed.protocol !== url.protocol || parsed.host !== url.host) return false;
- return parsed.pathname === "/" || parsed.pathname === url.pathname;
+ // CSP path matching: a source path ending in "/" matches by prefix, otherwise it
+ // must match exactly. The query string is never part of the match.
+ return parsed.pathname.endsWith("/")
+ ? url.pathname.startsWith(parsed.pathname)
+ : parsed.pathname === url.pathname;
});
}
@@ -104,9 +108,9 @@ describe("parseCspImageOrigins", () => {
});
describe("buildImgSrcDirective", () => {
- it("is self, data, blob, the SSO avatar hosts and the favicon endpoint by default", () => {
+ it("is self, data, blob, the SSO avatar hosts, the favicon endpoints and the changelog by default", () => {
expect(buildImgSrcDirective()).toBe(
- "img-src 'self' data: blob: https://avatars.githubusercontent.com https://lh3.googleusercontent.com https://www.google.com/s2/favicons"
+ "img-src 'self' data: blob: https://avatars.githubusercontent.com https://lh3.googleusercontent.com https://www.google.com/s2/favicons https://t0.gstatic.com/faviconV2 https://t1.gstatic.com/faviconV2 https://t2.gstatic.com/faviconV2 https://t3.gstatic.com/faviconV2 https://trigger.dev/changelog/"
);
});
@@ -120,6 +124,29 @@ describe("buildImgSrcDirective", () => {
);
});
+ it("permits the gstatic shard the favicon endpoint redirects to", () => {
+ expect(
+ directivePermits(
+ buildImgSrcDirective(),
+ "https://t2.gstatic.com/faviconV2?url=https://example.com&size=128"
+ )
+ ).toBe(true);
+ });
+
+ it("permits nothing else on a gstatic shard, and no shard we did not list", () => {
+ const directive = buildImgSrcDirective();
+ expect(directivePermits(directive, "https://t2.gstatic.com/beacon.png")).toBe(false);
+ expect(directivePermits(directive, "https://t9.gstatic.com/faviconV2")).toBe(false);
+ });
+
+ it("permits changelog images by path prefix, and nothing else on our domain", () => {
+ const directive = buildImgSrcDirective();
+ expect(directivePermits(directive, "https://trigger.dev/changelog/some-post/image.png")).toBe(
+ true
+ );
+ expect(directivePermits(directive, "https://trigger.dev/anything.png")).toBe(false);
+ });
+
it("permits both OAuth avatar hosts", () => {
const directive = buildImgSrcDirective();
expect(directivePermits(directive, "https://avatars.githubusercontent.com/u/1?v=4")).toBe(true);
diff --git a/apps/webapp/app/utils/cspImageOrigins.ts b/apps/webapp/app/utils/cspImageOrigins.ts
index ed6fc03061..267c72df92 100644
--- a/apps/webapp/app/utils/cspImageOrigins.ts
+++ b/apps/webapp/app/utils/cspImageOrigins.ts
@@ -6,9 +6,12 @@
*/
/**
- * Always allowed: own origin, inline data, object URLs, the SSO avatar hosts, and the
- * favicon endpoint org avatars are stored as (see `utils/favicon.ts`). The path pins
- * that one endpoint — CSP matches the path and ignores the query string.
+ * Always allowed: own origin, inline data, object URLs, the SSO avatar hosts, the
+ * favicon endpoint org avatars are stored as (see `utils/favicon.ts`), and our own
+ * changelog images. The path pins each endpoint — CSP matches the path and ignores the
+ * query string. The favicon endpoint 302s to a `tN.gstatic.com` shard and CSP re-checks
+ * only the host on a redirect, so the shards are listed too; their path pin limits
+ * direct loads only. A trailing "/" matches by prefix.
*/
export const BASE_IMG_SRC_SOURCES = [
"'self'",
@@ -17,6 +20,11 @@ export const BASE_IMG_SRC_SOURCES = [
"https://avatars.githubusercontent.com",
"https://lh3.googleusercontent.com",
"https://www.google.com/s2/favicons",
+ "https://t0.gstatic.com/faviconV2",
+ "https://t1.gstatic.com/faviconV2",
+ "https://t2.gstatic.com/faviconV2",
+ "https://t3.gstatic.com/faviconV2",
+ "https://trigger.dev/changelog/",
] as const;
export type RejectedOrigin = { value: string; reason: string };
diff --git a/apps/webapp/test/dashboardAgentImageCsp.test.ts b/apps/webapp/test/dashboardAgentImageCsp.test.ts
index 52406fffd3..ff716623a7 100644
--- a/apps/webapp/test/dashboardAgentImageCsp.test.ts
+++ b/apps/webapp/test/dashboardAgentImageCsp.test.ts
@@ -24,6 +24,10 @@ describe("document image CSP", () => {
expect(directive).not.toMatch(/(^|\s)https?:(\s|$)/);
});
+ it("allows changelog images", () => {
+ expect(buildImgSrcDirective().split(" ")).toContain("https://trigger.dev/changelog/");
+ });
+
it("sets the header on every document response, not only on /login", () => {
// The set() call must sit outside the /login branch.
const loginBranch = source.slice(