Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
49eb5ae
fix(webapp): clip org avatar and fall back on image load error
kathiekiwi Aug 13, 2026
7101d37
fix(webapp): allow gstatic favicon redirects and changelog images in …
kathiekiwi Aug 13, 2026
bb93590
fix(webapp): reset avatar error state when the image url changes
kathiekiwi Aug 13, 2026
50af0b0
fix(webapp): detect avatar images that failed before hydration
kathiekiwi Aug 13, 2026
0e0f566
fix(webapp): correct img-src redirect comment and tighten changelog test
kathiekiwi Aug 13, 2026
edcf7f8
fix(webapp): keep the avatar fallback accessible
kathiekiwi Aug 13, 2026
13f3ad1
chore(webapp): drop the server-changes note
kathiekiwi Aug 13, 2026
74a882a
fix(webapp): highlight the checked radio card in dark themes
kathiekiwi Aug 13, 2026
7eb5a91
fix(webapp): keep the selected api key card indigo on hover
kathiekiwi Aug 13, 2026
75fd9cc
fix(webapp): darken unchecked radio cards in dark themes
kathiekiwi Aug 13, 2026
5ab86b4
fix(webapp): tone down the checked radio card grey
kathiekiwi Aug 13, 2026
3e6c1ba
fix(webapp): wire the selected surface tokens into the contrast slider
kathiekiwi Aug 13, 2026
2cb5e31
fix(webapp): lighten the selected radio card in the light theme
kathiekiwi Aug 13, 2026
068a810
fix(webapp): soften the selected radio card further in light
kathiekiwi Aug 13, 2026
c5e4316
fix(webapp): give radio cards their own hover surface
kathiekiwi Aug 13, 2026
0b6ed3e
fix(webapp): soften the selected radio card under the contrast slider
kathiekiwi Aug 13, 2026
f5772b7
fix(webapp): flatten the radio ramp at high contrast in light
kathiekiwi Aug 13, 2026
f549a98
fix(webapp): carry light high-contrast on radio borders instead of ba…
kathiekiwi Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions apps/webapp/app/components/primitives/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -76,7 +77,7 @@ export function Avatar({
/>
);
case "image":
return <AvatarImage avatar={avatar} size={size} />;
return <AvatarImage key={avatar.url} avatar={avatar} size={size} />;
}
}

Expand Down Expand Up @@ -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 (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<span
role="img"
aria-label="Organization avatar"
className="grid shrink-0 place-items-center overflow-hidden"
style={styleFromSize(size)}
>
<GlobeLinesIcon className="size-[90%] text-text-dimmed" />
</span>
Comment thread
kathiekiwi marked this conversation as resolved.
);
}

return (
<span className="grid shrink-0 place-items-center" style={styleFromSize(size)}>
<span className="grid shrink-0 place-items-center overflow-hidden" style={styleFromSize(size)}>
<img
ref={detectFailedLoad}
src={avatar.url}
alt="Organization avatar"
className="size-full rounded-[10%] object-contain"
onError={() => setFailed(true)}
/>
</span>
);
Expand Down
8 changes: 4 additions & 4 deletions apps/webapp/app/components/primitives/RadioButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,31 +22,31 @@ 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",
icon: "w-8 h-8 mb-2",
},
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",
icon: "w-8 h-8 mb-2",
},
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",
icon: "w-8 h-8 mb-2",
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" ? (
<span className="flex items-center gap-2">
Expand Down
19 changes: 19 additions & 0 deletions apps/webapp/app/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
kathiekiwi marked this conversation as resolved.
--color-surface-control: var(--color-charcoal-600);
--color-surface-control-hover: var(--color-charcoal-550);
--color-surface-control-active: var(--color-charcoal-500);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Comment thread
kathiekiwi marked this conversation as resolved.
--color-border-bright: #d2d5db;
--color-border-brighter: #b9bdc7;
--color-border-brightest: #9ba1ad;
Expand Down Expand Up @@ -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%));

Expand All @@ -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
Expand All @@ -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%));
Expand Down
33 changes: 30 additions & 3 deletions apps/webapp/app/utils/cspImageOrigins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
}

Expand Down Expand Up @@ -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/"
);
});

Expand All @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions apps/webapp/app/utils/cspImageOrigins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand All @@ -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/",
Comment thread
kathiekiwi marked this conversation as resolved.
Comment thread
kathiekiwi marked this conversation as resolved.
Comment thread
kathiekiwi marked this conversation as resolved.
] as const;

export type RejectedOrigin = { value: string; reason: string };
Expand Down
4 changes: 4 additions & 0 deletions apps/webapp/test/dashboardAgentImageCsp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading