Files
Katia Bulatova fbd6df33b4 feat(webapp): Themes + contrast settings update (#4206)
Adds System Preferences, Dark and Light themes, gated by the
`hasThemeSwitcher` feature flag (off by default — dark stays the default
theme for everyone).

Old theme is now "Classic"and set as default. 
"System preferences" theme has both Light and Dark modes and uses your
laptop settings to use a correct one.
It has less color accents (specifically less colored text), and they are
the same for both modes, only grayscale values change between them. And
Light/Dark themes can be used separately.

New Contrast setting is available for System Preferences, Dark and Light
themes - it changes the contrast for the whole app. All new visual
Settings live in Account.
2026-08-03 19:29:33 +02:00

404 lines
16 KiB
TypeScript

import { AdjustmentsHorizontalIcon, PauseIcon, PlayIcon } from "@heroicons/react/20/solid";
import { DialogClose } from "@radix-ui/react-dialog";
import { Form, useNavigation } from "@remix-run/react";
import type { QueueItem } from "@trigger.dev/core/v3/schemas";
import { useEffect, useState } from "react";
import { cn } from "~/utils/cn";
import { Button, type ButtonVariant } from "~/components/primitives/Buttons";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
import { FormButtons } from "~/components/primitives/FormButtons";
import { Hint } from "~/components/primitives/Hint";
import { Input } from "~/components/primitives/Input";
import { InputGroup } from "~/components/primitives/InputGroup";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { PopoverMenuItem } from "~/components/primitives/Popover";
import SegmentedControl from "~/components/primitives/SegmentedControl";
import { Spinner } from "~/components/primitives/Spinner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/primitives/Tooltip";
// Per-queue action controls. Extracted from the Queues list route so the queue detail page can
// reuse them. Both submit a `<Form method="post">` to the current route, so whichever route renders
// them must handle the `queue-pause` / `queue-resume` / `queue-override` / `queue-remove-override`
// actions (see `handleQueueMutationAction` in `~/models/queueMutation.server`).
export function QueuePauseResumeButton({
queue,
variant = "tertiary/small",
fullWidth = false,
showTooltip = true,
iconOnly = false,
withQueueName = false,
}: {
/** The "id" here is a friendlyId */
queue: { id: string; name: string; paused: boolean };
variant?: ButtonVariant;
fullWidth?: boolean;
showTooltip?: boolean;
/** Icon-only trigger (label moves to the tooltip). For compact placements like the detail-page
* live blocks. */
iconOnly?: boolean;
/** Render the full "Pause/Resume {name} queue" label instead of the short "Pause"/"Resume". */
withQueueName?: boolean;
}) {
const [isOpen, setIsOpen] = useState(false);
const label = queue.paused
? `Resumes the "${queue.name}" queue so its runs can be dequeued again.`
: `Pauses all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`;
const trigger = showTooltip ? (
<div>
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className={iconOnly ? "cursor-pointer [&_button]:cursor-pointer" : undefined}>
<DialogTrigger asChild>
<Button
type="button"
variant={variant}
className={cn(
iconOnly &&
(queue.paused
? "system:border-transparent system:bg-success system:transition system:group-hover/button:bg-success system:group-hover/button:brightness-90"
: "system:border-transparent system:bg-warning system:transition system:group-hover/button:bg-warning system:group-hover/button:brightness-90"),
withQueueName &&
(queue.paused
? "border-success/60 text-success [&_span]:text-success hover:border-success"
: "border-warning/60 text-warning [&_span]:text-warning hover:border-warning")
)}
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={cn(
queue.paused ? "text-success" : "text-warning",
iconOnly && "system:text-white"
)}
fullWidth={fullWidth}
textAlignLeft={fullWidth}
aria-label={label}
>
{iconOnly
? undefined
: withQueueName
? queue.paused
? "Resume this queue…"
: "Pause this queue…"
: queue.paused
? "Resume"
: "Pause"}
</Button>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className={"text-xs"}>
{label}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : (
<DialogTrigger asChild>
<PopoverMenuItem
icon={queue.paused ? PlayIcon : PauseIcon}
leadingIconClassName={queue.paused ? "text-success" : "text-warning"}
title={queue.paused ? "Resume..." : "Pause..."}
/>
</DialogTrigger>
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{trigger}
<DialogContent>
<DialogHeader>{queue.paused ? "Resume queue?" : "Pause queue?"}</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
<Paragraph>
{queue.paused
? `This will allow runs to be dequeued in the "${queue.name}" queue again.`
: `This will pause all runs from being dequeued in the "${queue.name}" queue. Any executing runs will continue to run.`}
</Paragraph>
<Form method="post" onSubmit={() => setIsOpen(false)}>
<input
type="hidden"
name="action"
value={queue.paused ? "queue-resume" : "queue-pause"}
/>
<input type="hidden" name="friendlyId" value={queue.id} />
<FormButtons
confirmButton={
<Button
type="submit"
shortcut={{ modifiers: ["mod"], key: "enter" }}
variant={queue.paused ? "primary/medium" : "danger/medium"}
LeadingIcon={queue.paused ? PlayIcon : PauseIcon}
>
{queue.paused ? "Resume queue" : "Pause queue"}
</Button>
}
cancelButton={
<DialogClose asChild>
<Button type="button" variant="secondary/medium">
Cancel
</Button>
</DialogClose>
}
/>
</Form>
</div>
</DialogContent>
</Dialog>
);
}
export function QueueOverrideConcurrencyButton({
queue,
environmentConcurrencyLimit,
trigger,
}: {
queue: QueueItem & { concurrencyLimitOverridePercent: number | null };
environmentConcurrencyLimit: number;
/** How to render the dialog trigger. "menu-item" (default) is a PopoverMenuItem for row menus;
* "button" is a standalone labeled button; "icon" is an icon-only button with the label in a
* hover tooltip, for compact placements like the detail-page live blocks. */
trigger?: "menu-item" | "button" | "icon";
}) {
const navigation = useNavigation();
const [isOpen, setIsOpen] = useState(false);
const [mode, setMode] = useState<"absolute" | "percent">(
queue.concurrencyLimitOverridePercent !== null ? "percent" : "absolute"
);
const [concurrencyLimit, setConcurrencyLimit] = useState<string>(
queue.concurrencyLimit?.toString() ?? environmentConcurrencyLimit.toString()
);
const [percent, setPercent] = useState<string>(
queue.concurrencyLimitOverridePercent?.toString() ?? "100"
);
const isOverridden = !!queue.concurrency?.overriddenAt;
const currentLimit = queue.concurrencyLimit ?? environmentConcurrencyLimit;
useEffect(() => {
if (navigation.state === "loading" || navigation.state === "idle") {
setIsOpen(false);
}
}, [navigation.state]);
const isLoading = Boolean(
navigation.formData?.get("action") === "queue-override" ||
navigation.formData?.get("action") === "queue-remove-override"
);
// Client-side mirror of the backend cap + materialization, so the user sees the resolved value
// and can't submit an above-limit override.
const percentNumber = Number(percent);
const percentValid = Number.isFinite(percentNumber) && percentNumber > 0 && percentNumber <= 100;
const materializedFromPercent = percentValid
? Math.min(
Math.max(Math.floor((environmentConcurrencyLimit * percentNumber) / 100), 1),
environmentConcurrencyLimit
)
: null;
const limitNumber = Number(concurrencyLimit);
const limitOverCap = Number.isFinite(limitNumber) && limitNumber > environmentConcurrencyLimit;
const submitDisabled =
isLoading || (mode === "percent" ? !percentValid : !concurrencyLimit || limitOverCap);
const iconLabel = isOverridden ? "Edit override" : "Override limit";
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
{trigger === "icon" ? (
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-pointer [&_button]:cursor-pointer">
<DialogTrigger asChild>
<Button
type="button"
variant="secondary/small-icon"
LeadingIcon={AdjustmentsHorizontalIcon}
leadingIconClassName="text-text-dimmed"
aria-label={iconLabel}
/>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="right" className="text-xs">
{iconLabel}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : trigger === "button" ? (
<TooltipProvider disableHoverableContent={true}>
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-pointer [&_button]:cursor-pointer">
<DialogTrigger asChild>
<Button
type="button"
variant="secondary/small"
LeadingIcon={AdjustmentsHorizontalIcon}
leadingIconClassName="text-text-bright"
aria-label={
isOverridden ? "Edit concurrency override" : "Override concurrency limit"
}
>
{isOverridden ? "Edit override" : "Override limit"}
</Button>
</DialogTrigger>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[230px] text-xs">
Give this queue its own concurrency limit instead of the environment default. Set it
as a number or a percentage of the environment limit.
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<DialogTrigger asChild>
<PopoverMenuItem
icon={AdjustmentsHorizontalIcon}
title={isOverridden ? "Edit override…" : "Override limit…"}
/>
</DialogTrigger>
)}
<DialogContent>
<DialogHeader>
{isOverridden ? "Edit concurrency override" : "Override concurrency limit"}
</DialogHeader>
<div className="flex flex-col gap-3 pt-3">
{isOverridden ? (
<Paragraph variant="small">
This queue's concurrency limit is currently overridden to {currentLimit}.
{typeof queue.concurrency?.base === "number" &&
` The original limit set in code was ${queue.concurrency.base}.`}{" "}
You can update the override or remove it to restore the{" "}
{typeof queue.concurrency?.base === "number"
? "limit set in code"
: "environment concurrency limit"}
.
</Paragraph>
) : (
<Paragraph variant="small">
Override this queue's concurrency limit. The current limit is {currentLimit}, which is
set {queue.concurrencyLimit !== null ? "in code" : "by the environment"}.
</Paragraph>
)}
<Form method="post" onSubmit={() => setIsOpen(false)} className="space-y-3">
<input type="hidden" name="friendlyId" value={queue.id} />
<input type="hidden" name="mode" value={mode} />
<InputGroup fullWidth>
<Label htmlFor={mode === "percent" ? "percent" : "concurrencyLimit"}>
Concurrency limit
</Label>
<div className="flex items-center gap-2">
<div className="flex-1">
{mode === "percent" ? (
<Input
type="number"
name="percent"
id="percent"
min="1"
max="100"
step="0.01"
value={percent}
onChange={(e) => setPercent(e.target.value)}
placeholder="100"
autoFocus
accessory={<span className="pr-1 text-text-dimmed">%</span>}
/>
) : (
<Input
type="number"
name="concurrencyLimit"
id="concurrencyLimit"
min="0"
max={environmentConcurrencyLimit}
value={concurrencyLimit}
onChange={(e) => setConcurrencyLimit(e.target.value)}
placeholder={currentLimit.toString()}
autoFocus
/>
)}
</div>
<SegmentedControl
name="unit"
value={mode}
className="h-8"
options={[
{ label: "Number", value: "absolute" },
{ label: "Percent", value: "percent" },
]}
onChange={(value) => setMode(value === "percent" ? "percent" : "absolute")}
/>
</div>
{mode === "percent" ? (
<Hint className="tabular-nums">
{materializedFromPercent !== null
? `${percentNumber}% = ${materializedFromPercent} concurrent ${
materializedFromPercent === 1 ? "run" : "runs"
} of the environment's ${environmentConcurrencyLimit}. Recalculates automatically when the environment limit changes.`
: "Enter a percentage between 1 and 100."}
</Hint>
) : (
<Hint className={limitOverCap ? "text-warning tabular-nums" : "tabular-nums"}>
{limitOverCap
? `Can't exceed the environment limit of ${environmentConcurrencyLimit}.`
: `The most concurrent runs this queue can use at once. It can't exceed the environment limit of ${environmentConcurrencyLimit}.`}
</Hint>
)}
</InputGroup>
<FormButtons
defaultAction={{
name: "action",
value: "queue-override",
disabled: submitDisabled,
}}
confirmButton={
<Button
type="submit"
name="action"
value="queue-override"
disabled={submitDisabled}
variant="primary/medium"
LeadingIcon={isLoading && <Spinner color="white" />}
shortcut={{ modifiers: ["mod"], key: "enter" }}
>
{isOverridden ? "Update override" : "Override limit"}
</Button>
}
cancelButton={
<div className="flex items-center justify-between gap-2">
{isOverridden && (
<Button
type="submit"
name="action"
value="queue-remove-override"
disabled={isLoading}
variant="danger/medium"
>
Remove override
</Button>
)}
<DialogClose asChild>
<Button type="button" variant="secondary/medium">
Cancel
</Button>
</DialogClose>
</div>
}
/>
</Form>
</div>
</DialogContent>
</Dialog>
);
}