feat(webapp): themes refinement, new black & white themes, 2 accessibility toggles (#4547)
## What this does Rounds out the theme work behind the existing `hasThemeSwitcher` flag. **Two new themes.** Black and White sit alongside Dark and Light. They inherit their neighbour's whole token set and only pin their surfaces flat, so sections are separated by grid lines rather than layered fills. **`System` is now configurable at both ends.** You choose which theme the OS light setting lands on (Light or White) and which the dark setting lands on (Dark or Black). **Two accessibility toggles.** - *Stronger colors* — swaps tinted status chips for solid fills, drops decorative icon accents to monochrome, and darkens chart series that didn't clear 3:1 on a white plot. - *Underline links* — underlines body-text links, so an underline always means the preference is on rather than being a hover style. **Contrast slider.** Stores a 0–100 position within the active theme's own range rather than a shared scale, so 35% stays 35% when you switch themes. Each theme maps it in CSS, which keeps `system` working before hydration. **Appearance in the account popover.** A submenu listing the themes with a check against the current one, plus a link through to the full set on your profile. Picking one applies immediately rather than waiting for the write to round-trip. **Profile page.** Each row now saves on its own — no submit button. Name and email show their value inline with an edit button; the email row is read-only when an identity provider owns the address. **A `/storybook/colors` audit page.** Renders every colour-carrying pattern in the app once per theme plus once under Stronger colors, and measures contrast ratios off the live DOM rather than a hard-coded table, so it can't go stale. --- ## Demo https://github.com/user-attachments/assets/d56cd4d8-719f-4ec5-a990-e04cdb98def1 --- ## Compatibility The stored preference shape is unchanged (`version: "1"`), and the four new fields are all optional. The retired `classic` theme falls back to Dark, whose palette at contrast 0 is what Classic shipped. One deliberate change worth knowing: the default contrast moves from 50 to 0, so existing users who never touched the slider will see slightly less contrast than before. That's what makes 0 mean "the base palette". --- ## Testing Switched between every theme from both the account popover and the profile page, in the expanded and collapsed rail, checking `data-theme` follows and survives a reload. Dragged the contrast slider in each theme and confirmed the percentage label tracks the handle and resnaps if a save fails. Checked both accessibility toggles across the `/storybook/colors` page, which is also where the contrast ratios were read from. Confirmed the Appearance entry stays hidden for a non-admin while the flag is off. <!-- conductor-workspace-link --> --- [Open workspace in Conductor](https://app.conductor.build/workspace/fee50611-7623-4422-bada-ed1cba317ed1) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
The dashboard has two new themes, Black and White, plus appearance options for stronger colors and underlined links.
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Solid circle. Paired with {@link CircleOutlineIcon} by the Black and White
|
||||
* theme options — the filled disc reads as the opposite of the active theme. */
|
||||
export function CircleFilledIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/** Hollow circle. Paired with {@link CircleFilledIcon} by the Black and White
|
||||
* theme options, which show the active theme's background through the ring. */
|
||||
export function CircleOutlineIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Pencil over a couple of text lines — editing a value in place. */
|
||||
export function EditPencilIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.7573 3.6275L20.3732 5.24335C21.1542 6.0244 21.1542 7.29073 20.3732 8.07178L9.72032 18.7246C9.57777 18.8671 9.3957 18.9631 9.19759 19.0002L4.03377 19.9669L5.00052 14.8031C5.03765 14.6051 5.1336 14.4229 5.27604 14.2804L15.9289 3.6275C16.71 2.84645 17.9763 2.84645 18.7573 3.6275Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<line x1="17.6464" y1="10.3536" x2="13.6464" y2="6.35355" stroke="currentColor" />
|
||||
<path d="M13 21L21 21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<path d="M18 17L21 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Monitor on a stand — the System theme, which follows the OS appearance. */
|
||||
export function MonitorIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21 13H3M11 17H13L14 21H10L11 17ZM5 17H19C20.1046 17 21 16.1046 21 15V6C21 4.89543 20.1046 4 19 4H5C3.89543 4 3 4.89543 3 6V15C3 16.1046 3.89543 17 5 17Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="square"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Crescent moon — the dark theme. */
|
||||
export function MoonIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M20.9638 12.7674C19.8361 13.5447 18.4693 13.9998 16.9961 13.9998C13.1301 13.9998 9.99609 10.8657 9.99609 6.99975C9.99609 5.52667 10.4511 4.15987 11.2283 3.03223C6.61911 3.42277 3 7.28768 3 11.9979C3 16.9674 7.0286 20.996 11.9981 20.996C16.7084 20.996 20.5734 17.3767 20.9638 12.7674Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export function SunIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M15.5355 8.46447C17.4882 10.4171 17.4882 13.5829 15.5355 15.5355C13.5829 17.4882 10.4171 17.4882 8.46447 15.5355C6.51184 13.5829 6.51184 10.4171 8.46447 8.46447C10.4171 6.51184 13.5829 6.51184 15.5355 8.46447Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 3V1M12 23V21M21 12H23M1 12H3M5.63603 5.63604L4.22182 4.22183M19.7782 19.7782L18.364 18.364M18.364 5.63606L19.7782 4.22184M4.22183 19.7782L5.63605 18.364"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Toggle switch, knob to the left. */
|
||||
export function ToggleSwitchIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M15.5 5H8.5C4.63401 5 1.5 8.13401 1.5 12C1.5 15.866 4.63401 19 8.5 19H15.5C19.366 19 22.5 15.866 22.5 12C22.5 8.13401 19.366 5 15.5 5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 15C10.1569 15 11.5 13.6569 11.5 12C11.5 10.3431 10.1569 9 8.5 9C6.84315 9 5.5 10.3431 5.5 12C5.5 13.6569 6.84315 15 8.5 15Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.5 KiB |
@@ -20,6 +20,7 @@ import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
|
||||
export const billingLimitFormSchema = z.discriminatedUnion("mode", [
|
||||
z.object({
|
||||
@@ -339,10 +340,7 @@ function LimitReachedCalloutContent({
|
||||
When this limit is reached, queued runs will be held for {gracePeriodLabel}, then new triggers
|
||||
will be rejected until you increase or remove the limit. Limits are enforced with a short
|
||||
delay, so spend may briefly exceed the limit before grace begins. See our{" "}
|
||||
<a href="https://trigger.dev/terms" className="underline">
|
||||
terms
|
||||
</a>{" "}
|
||||
for refund policy details.
|
||||
<TextLink href="https://trigger.dev/terms">terms</TextLink> for refund policy details.
|
||||
{cancelInProgressRuns ? (
|
||||
<> In-progress runs will be cancelled when the limit is hit.</>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { textLinkClassName } from "~/components/primitives/TextLink";
|
||||
import { useThemeColor } from "~/hooks/useThemeColor";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
@@ -31,7 +32,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
|
||||
<ArrowUpCircleIcon className="h-5 w-5 shrink-0 text-text-dimmed" />
|
||||
<span className="truncate text-2sm text-text-bright">Free Plan</span>
|
||||
</div>
|
||||
<Link to={to} className="shrink-0 text-2sm text-text-link focus-custom">
|
||||
<Link to={to} className={cn(textLinkClassName(), "shrink-0 text-2sm")}>
|
||||
Upgrade
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { AgentMonoLogo } from "~/components/primitives/AgentDotMatrix";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { textLinkClassName } from "~/components/primitives/TextLink";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { ASK_AGENT_LABEL } from "./agent-identity";
|
||||
@@ -48,10 +49,7 @@ export function AgentQuotaNotice({ remaining, limit }: { remaining: number; limi
|
||||
{remaining} of {limit} free messages left
|
||||
</span>
|
||||
<span aria-hidden>·</span>
|
||||
<Link
|
||||
to={v3BillingPath(organization)}
|
||||
className="text-text-link underline-offset-2 hover:underline"
|
||||
>
|
||||
<Link to={v3BillingPath(organization)} className={textLinkClassName()}>
|
||||
Upgrade
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -54,11 +54,13 @@ export function DashboardAgentComposer({
|
||||
const sendButton = isStreaming ? (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
className="aspect-square h-7 min-w-0 bg-charcoal-600 p-1 hover:bg-charcoal-550"
|
||||
className="aspect-square h-7 min-w-0 bg-surface-control p-1 hover:bg-surface-control-hover"
|
||||
aria-label="Stop generating"
|
||||
tooltip="Stop generating"
|
||||
onClick={onStop}
|
||||
LeadingIcon={<StopIcon className="size-4 text-white" />}
|
||||
// Not text-white: this button's surface goes light with the theme, unlike
|
||||
// the indigo Send button below it.
|
||||
LeadingIcon={<StopIcon className="size-4 text-text-bright" />}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
SeverityBadge,
|
||||
VerdictBadge,
|
||||
} from "./agent-badges";
|
||||
import { textLinkClassName } from "~/components/primitives/TextLink";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { AgentCard, AgentCardBody, AgentCardHeader } from "./agent-card";
|
||||
import { ChatActionsRow } from "./chat-layout";
|
||||
import type { ResolvedUri } from "./ReportView";
|
||||
@@ -65,7 +67,7 @@ function EvidenceItem({
|
||||
{resolved ? (
|
||||
<a
|
||||
href={resolved.url}
|
||||
className="block break-all font-mono text-[10px] text-text-link transition hover:underline"
|
||||
className={cn(textLinkClassName(), "block break-all font-mono text-[10px]")}
|
||||
>
|
||||
{resolved.label}
|
||||
</a>
|
||||
|
||||
@@ -101,7 +101,7 @@ function RunLink({ runId, className }: { runId: string; className?: string }) {
|
||||
const to = useRunPath(runId);
|
||||
if (!to) return <span className={cn("font-mono text-text-dimmed", className)}>{runId}</span>;
|
||||
return (
|
||||
<TextLink to={to} variant="token" className={cn("underline", className)}>
|
||||
<TextLink to={to} className={className}>
|
||||
{runId}
|
||||
</TextLink>
|
||||
);
|
||||
@@ -116,10 +116,9 @@ function EvidenceReference({ reference }: { reference: string }) {
|
||||
return (
|
||||
<TextLink
|
||||
href={safeUrl}
|
||||
variant="token"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-xs underline"
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{reference}
|
||||
</TextLink>
|
||||
|
||||
@@ -15,13 +15,12 @@ export type AgentTone = "neutral" | "success" | "warning" | "error";
|
||||
// The `system:` overrides stop the Badge `small` variant tinting every chip blue.
|
||||
const TONE_BADGE: Record<AgentTone, string> = {
|
||||
neutral:
|
||||
"border-border-bright text-text-dimmed system:border-transparent system:bg-charcoal-500/10 system:text-text-dimmed",
|
||||
"border-border-bright text-text-dimmed system:border-transparent system:bg-charcoal-500 system:text-white",
|
||||
success:
|
||||
"border-success/40 text-success system:border-transparent system:bg-success/10 system:text-success",
|
||||
"border-success/40 text-success system:border-transparent system:bg-success system:text-white",
|
||||
warning:
|
||||
"border-warning/40 text-warning system:border-transparent system:bg-warning/10 system:text-warning",
|
||||
error:
|
||||
"border-error/40 text-error system:border-transparent system:bg-error/10 system:text-error",
|
||||
"border-warning/40 text-warning system:border-transparent system:bg-warning system:text-white",
|
||||
error: "border-error/40 text-error system:border-transparent system:bg-error system:text-white",
|
||||
};
|
||||
|
||||
export const TONE_ICON_COLOR: Record<AgentTone, string> = {
|
||||
@@ -48,8 +47,9 @@ export function AgentBadge({
|
||||
<Badge
|
||||
variant="small"
|
||||
className={cn(
|
||||
// `contrast-chip`: the tinted chip gains a ring as interface contrast rises.
|
||||
"contrast-chip px-1.5 [&>span]:flex [&>span]:items-center [&>span]:gap-1",
|
||||
// No `contrast-chip`: these fill solid, so its currentcolor ring would
|
||||
// land as a white line inset into the fill.
|
||||
"px-1.5 [&>span]:flex [&>span]:items-center [&>span]:gap-1",
|
||||
TONE_BADGE[tone],
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -34,7 +34,9 @@ export function ErrorStatusBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"contrast-chip inline-flex items-center rounded px-2 py-0.5 text-xs font-medium",
|
||||
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium",
|
||||
// The contrast ring is for tints; `bright` fills solid.
|
||||
prominence !== "bright" && "contrast-chip",
|
||||
(prominence === "bright" ? brightStyles : subtleStyles)[status],
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -12,15 +12,15 @@ import {
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { appliedSummary } from "~/components/runs/v3/SharedFilters";
|
||||
import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LogLevel } from "~/components/logs/LogLevel";
|
||||
import type { LogLevel as LogLevelValue } from "~/presenters/v3/LogsListPresenter.server";
|
||||
|
||||
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
|
||||
{ level: "TRACE", label: "Trace", color: "text-purple-400" },
|
||||
{ level: "INFO", label: "Info", color: "text-blue-400" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "DEBUG", label: "Debug", color: "text-text-dimmed" },
|
||||
const allLogLevels: { level: LogLevelValue; label: string }[] = [
|
||||
{ level: "TRACE", label: "Trace" },
|
||||
{ level: "INFO", label: "Info" },
|
||||
{ level: "WARN", label: "Warning" },
|
||||
{ level: "ERROR", label: "Error" },
|
||||
{ level: "DEBUG", label: "Debug" },
|
||||
];
|
||||
|
||||
// In the future we might add other levels or change which are available
|
||||
@@ -28,23 +28,6 @@ function getAvailableLevels(): typeof allLogLevels {
|
||||
return allLogLevels;
|
||||
}
|
||||
|
||||
function getLevelBadgeColor(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
return "text-error bg-error/10 border-error/20";
|
||||
case "WARN":
|
||||
return "text-warning bg-warning/10 border-warning/20";
|
||||
case "TRACE":
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
|
||||
case "DEBUG":
|
||||
return "text-text-dimmed bg-background-raised border-border-bright";
|
||||
case "INFO":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
default:
|
||||
return "text-text-dimmed bg-background-hover border-grid-bright";
|
||||
}
|
||||
}
|
||||
|
||||
const shortcut = { key: "l" };
|
||||
|
||||
export function LogsLevelFilter() {
|
||||
@@ -93,14 +76,9 @@ function LevelDropdown({ trigger }: { trigger: ReactNode }) {
|
||||
value={item.level}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase",
|
||||
getLevelBadgeColor(item.level)
|
||||
)}
|
||||
>
|
||||
{item.level}
|
||||
</span>
|
||||
{/* The same chip the rows use, so the dropdown can't drift from the list */}
|
||||
<LogLevel level={item.level} />
|
||||
<span className="sr-only">{item.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect } from "react";
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import { ToggleSwitchIcon } from "~/assets/icons/ToggleSwitchIcon";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { THEME_OPTIONS } from "~/components/themeOptions";
|
||||
import { applyThemePreference } from "~/hooks/useSystemThemeSync";
|
||||
import { type loader as rootLoader } from "~/root";
|
||||
import { accountPath } from "~/utils/pathBuilder";
|
||||
import { normalizeThemePreference, type ThemePreference } from "~/utils/themePreference";
|
||||
import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu";
|
||||
import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes";
|
||||
|
||||
const THEME_ACTION_PATH = "/resources/preferences/theme";
|
||||
|
||||
export function AppearanceMenuItem() {
|
||||
const rootData = useTypedRouteLoaderData<typeof rootLoader>("root");
|
||||
const fetcher = useFetcher<{ success?: boolean }>();
|
||||
const savedTheme = rootData?.themePreference;
|
||||
const systemThemes = rootData?.systemThemes;
|
||||
|
||||
// A failed write would otherwise leave the optimistic theme on screen.
|
||||
useEffect(() => {
|
||||
if (fetcher.state !== "idle" || !fetcher.data || fetcher.data.success || !savedTheme) return;
|
||||
applyThemePreference(savedTheme, systemThemes);
|
||||
}, [fetcher.state, fetcher.data, savedTheme, systemThemes]);
|
||||
|
||||
if (!rootData?.showThemeSwitcher) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pendingTheme = fetcher.formData?.get("theme");
|
||||
const theme =
|
||||
typeof pendingTheme === "string"
|
||||
? normalizeThemePreference(pendingTheme)
|
||||
: rootData.themePreference;
|
||||
|
||||
const pickTheme = (value: ThemePreference) => {
|
||||
// Dismissing the popover unmounts this row, and an unmounted fetcher's
|
||||
// revalidation is dropped, so apply the theme here rather than waiting.
|
||||
applyThemePreference(value, rootData.systemThemes);
|
||||
fetcher.submit({ theme: value }, { method: "post", action: THEME_ACTION_PATH });
|
||||
};
|
||||
|
||||
return (
|
||||
<SideMenuPopoverSubMenu title="Appearance" icon={ToggleSwitchIcon} contentClassName="min-w-36">
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{THEME_OPTIONS.map((option) => (
|
||||
<PopoverMenuItem
|
||||
key={option.value}
|
||||
title={option.label}
|
||||
icon={option.icon}
|
||||
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
|
||||
className={SIDE_MENU_POPOVER_ITEM_LABEL}
|
||||
isSelected={theme === option.value}
|
||||
onClick={() => pickTheme(option.value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<PopoverMenuItem
|
||||
to={accountPath()}
|
||||
title="More options"
|
||||
icon={EllipsisHorizontalIcon}
|
||||
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
|
||||
className={SIDE_MENU_POPOVER_ITEM_LABEL}
|
||||
/>
|
||||
</div>
|
||||
</SideMenuPopoverSubMenu>
|
||||
);
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export function EnvironmentSelector({
|
||||
className={ENV_POPOVER_ITEM_LABEL}
|
||||
iconClassName={ENV_POPOVER_ITEM_ICON}
|
||||
/>
|
||||
<span className={cn("text-indigo-500", ENV_POPOVER_ITEM_LABEL)}>Upgrade</span>
|
||||
<span className={cn("text-text-link", ENV_POPOVER_ITEM_LABEL)}>Upgrade</span>
|
||||
</div>
|
||||
}
|
||||
isSelected={false}
|
||||
@@ -213,7 +213,7 @@ export function EnvironmentSelector({
|
||||
className={ENV_POPOVER_ITEM_LABEL}
|
||||
iconClassName={ENV_POPOVER_ITEM_ICON}
|
||||
/>
|
||||
<span className={cn("text-indigo-500", ENV_POPOVER_ITEM_LABEL)}>Upgrade</span>
|
||||
<span className={cn("text-text-link", ENV_POPOVER_ITEM_LABEL)}>Upgrade</span>
|
||||
</div>
|
||||
}
|
||||
isSelected={false}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { textLinkClassName } from "~/components/primitives/TextLink";
|
||||
|
||||
export function NotificationCard({
|
||||
title,
|
||||
@@ -87,7 +88,7 @@ export function NotificationCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleExpand}
|
||||
className="relative z-20 mt-0.5 text-xs text-indigo-400 hover:text-indigo-300"
|
||||
className={cn(textLinkClassName(), "relative z-20 mt-0.5 text-xs")}
|
||||
>
|
||||
{isExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
@@ -109,7 +110,7 @@ function getMarkdownComponents(onLinkClick?: () => void) {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="relative z-20 text-indigo-400 underline transition-colors hover:text-indigo-300"
|
||||
className={cn(textLinkClassName(), "relative z-20")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onLinkClick?.();
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ChevronRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ArrowTopRightOnSquareIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Form,
|
||||
@@ -25,44 +21,27 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { AIChatIcon } from "~/assets/icons/AIChatIcon";
|
||||
import { AIPenIcon } from "~/assets/icons/AIPenIcon";
|
||||
import { ArrowLeftRightIcon } from "~/assets/icons/ArrowLeftRightIcon";
|
||||
import { ArrowRightSquareIcon } from "~/assets/icons/ArrowRightSquareIcon";
|
||||
import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon";
|
||||
import { BatchesIcon } from "~/assets/icons/BatchesIcon";
|
||||
import { BellIcon } from "~/assets/icons/BellIcon";
|
||||
import { Box3DIcon } from "~/assets/icons/Box3DIcon";
|
||||
import { BugIcon } from "~/assets/icons/BugIcon";
|
||||
import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon";
|
||||
import { ChartBarIcon } from "~/assets/icons/ChartBarIcon";
|
||||
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon";
|
||||
import { DialIcon } from "~/assets/icons/DialIcon";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { EyeClosedIcon } from "~/assets/icons/EyeClosedIcon";
|
||||
import { EyeOpenIcon } from "~/assets/icons/EyeOpenIcon";
|
||||
import { FolderClosedIcon } from "~/assets/icons/FolderClosedIcon";
|
||||
import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon";
|
||||
import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon";
|
||||
import { HomeIcon } from "~/assets/icons/HomeIcon";
|
||||
import { IDIcon } from "~/assets/icons/IDIcon";
|
||||
import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon";
|
||||
import { KeyIcon } from "~/assets/icons/KeyIcon";
|
||||
import { LeftSideMenuCollapsedIcon } from "~/assets/icons/LeftSideMenuCollapsedIcon";
|
||||
import { LeftSideMenuIcon } from "~/assets/icons/LeftSideMenuIcon";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { LogsIcon } from "~/assets/icons/LogsIcon";
|
||||
import { PlusIcon } from "~/assets/icons/PlusIcon";
|
||||
import { QueuesIcon } from "~/assets/icons/QueuesIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { ShieldIcon } from "~/assets/icons/ShieldIcon";
|
||||
import { SidebarCustomizeIcon } from "~/assets/icons/SidebarCustomizeIcon";
|
||||
import { SlidersIcon } from "~/assets/icons/SlidersIcon";
|
||||
import { TasksIcon } from "~/assets/icons/TasksIcon";
|
||||
import { UsageIcon } from "~/assets/icons/UsageIcon";
|
||||
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
||||
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
|
||||
import { UserCrossIcon } from "~/assets/icons/UserCrossIcon";
|
||||
import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
|
||||
@@ -95,10 +74,7 @@ import {
|
||||
accountSecurityPath,
|
||||
personalAccessTokensPath,
|
||||
adminPath,
|
||||
branchesPath,
|
||||
concurrencyPath,
|
||||
docsPath,
|
||||
limitsPath,
|
||||
logoutPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
@@ -108,36 +84,20 @@ import {
|
||||
organizationSsoPath,
|
||||
organizationTeamPath,
|
||||
organizationVercelIntegrationPath,
|
||||
queryPath,
|
||||
regionsPath,
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
v3BillingLimitsPath,
|
||||
v3BillingPath,
|
||||
v3PrivateConnectionsPath,
|
||||
v3BulkActionsPath,
|
||||
v3DashboardsLandingPath,
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3ErrorsPath,
|
||||
v3LogsPath,
|
||||
v3ModelsPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectSettingsGeneralPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
v3PromptsPath,
|
||||
v3QueuesPath,
|
||||
v3RunsPath,
|
||||
v3SessionsPath,
|
||||
v3UsagePath,
|
||||
v3WaitpointTokensPath,
|
||||
v3WebhooksPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
||||
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
|
||||
import { AlphaBadge, NewBadge } from "../FeatureBadges";
|
||||
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
|
||||
import { NewBadge } from "../FeatureBadges";
|
||||
import { Button, LinkButton } from "../primitives/Buttons";
|
||||
import { Dialog, DialogTrigger } from "../primitives/Dialog";
|
||||
import { type RenderIcon } from "../primitives/Icon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
@@ -160,6 +120,7 @@ import {
|
||||
} from "../primitives/Tooltip";
|
||||
import { ShortcutsAutoOpen } from "../Shortcuts";
|
||||
import { type FavoritePage } from "~/services/dashboardPreferences.server";
|
||||
import { AppearanceMenuItem } from "./AppearanceMenuItem";
|
||||
import {
|
||||
CustomizeSidebarDialog,
|
||||
type CustomizeSidebarSection,
|
||||
@@ -180,6 +141,8 @@ import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { NotificationPanel } from "./NotificationPanel";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem, SideMenuLabel } from "./SideMenuItem";
|
||||
import { buildSideMenuSections, type SideMenuSectionConfig } from "./sideMenuSections";
|
||||
import { SideMenuPopoverSubMenu } from "./SideMenuPopoverSubMenu";
|
||||
import { SideMenuSection } from "./SideMenuSection";
|
||||
import {
|
||||
isItemHidden,
|
||||
@@ -197,31 +160,6 @@ function getSectionCollapsed(
|
||||
return sideMenu?.collapsedSections?.[sectionId] ?? false;
|
||||
}
|
||||
|
||||
type SideMenuItemConfig = {
|
||||
/** Stable id used for hidden/order preferences; never rename once shipped. */
|
||||
id: string;
|
||||
name: string;
|
||||
icon: RenderIcon;
|
||||
activeIconColor: string;
|
||||
inactiveIconColor?: string;
|
||||
to: string;
|
||||
dataAction?: string;
|
||||
badge?: ReactNode;
|
||||
trailingIconClassName?: string;
|
||||
/** Hidden for every user who hasn't set their own preference for this item. */
|
||||
defaultHidden?: boolean;
|
||||
/** Right-side action (e.g. the + button on Dashboards); only rendered when visible. */
|
||||
action?: ReactNode;
|
||||
/** Extra content rendered directly after the item (e.g. the dashboards list). */
|
||||
after?: ReactNode;
|
||||
};
|
||||
|
||||
type SideMenuSectionConfig = {
|
||||
id: SideMenuSectionId;
|
||||
title: string;
|
||||
items: SideMenuItemConfig[];
|
||||
};
|
||||
|
||||
// Impersonation accent (menu border + "Stop impersonating"). Full class strings so Tailwind's
|
||||
// static scanner picks them up.
|
||||
const IMPERSONATION_ACCENT = {
|
||||
@@ -806,223 +744,32 @@ export function SideMenu({
|
||||
|
||||
// The customizable sections (everything except Tasks/Runs/Sessions), in DEFAULT order. The
|
||||
// user's saved order/hidden preferences are applied at render below.
|
||||
const staticSections: SideMenuSectionConfig[] = [];
|
||||
|
||||
if (isAdmin || featureFlags.hasAiAccess) {
|
||||
staticSections.push({
|
||||
id: "ai",
|
||||
title: "AI",
|
||||
items: [
|
||||
{
|
||||
id: "prompts",
|
||||
name: "Prompts",
|
||||
icon: AIPenIcon,
|
||||
trailingIconClassName: "size-6",
|
||||
activeIconColor: "text-aiPrompts",
|
||||
to: v3PromptsPath(organization, project, environment),
|
||||
dataAction: "prompts",
|
||||
badge: <NewBadge />,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
name: "Models",
|
||||
icon: Box3DIcon,
|
||||
activeIconColor: "text-models",
|
||||
to: v3ModelsPath(organization, project, environment),
|
||||
dataAction: "models",
|
||||
badge: <NewBadge />,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (isAdmin || featureFlags.hasQueryAccess || featureFlags.hasLogsPageAccess) {
|
||||
staticSections.push({
|
||||
id: "metrics",
|
||||
title: "Observability",
|
||||
items: [
|
||||
...(isAdmin || featureFlags.hasLogsPageAccess
|
||||
? [
|
||||
{
|
||||
id: "logs",
|
||||
name: "Logs",
|
||||
icon: LogsIcon,
|
||||
activeIconColor: "text-logs",
|
||||
to: v3LogsPath(organization, project, environment),
|
||||
dataAction: "logs",
|
||||
badge: <AlphaBadge />,
|
||||
} satisfies SideMenuItemConfig,
|
||||
]
|
||||
: []),
|
||||
...(isAdmin || featureFlags.hasQueryAccess
|
||||
? [
|
||||
{
|
||||
id: "errors",
|
||||
name: "Errors",
|
||||
icon: BugIcon,
|
||||
activeIconColor: "text-errors",
|
||||
to: v3ErrorsPath(organization, project, environment),
|
||||
dataAction: "errors",
|
||||
},
|
||||
{
|
||||
id: "query",
|
||||
name: "Query",
|
||||
icon: CodeSquareIcon,
|
||||
activeIconColor: "text-query",
|
||||
to: queryPath(organization, project, environment),
|
||||
dataAction: "query",
|
||||
},
|
||||
{
|
||||
id: "queues",
|
||||
name: "Queues",
|
||||
icon: QueuesIcon,
|
||||
activeIconColor: "text-queues",
|
||||
to: v3QueuesPath(organization, project, environment),
|
||||
dataAction: "queues",
|
||||
},
|
||||
{
|
||||
id: "dashboards",
|
||||
name: "Dashboards",
|
||||
icon: ChartBarIcon,
|
||||
activeIconColor: "text-metrics",
|
||||
to: v3DashboardsLandingPath(organization, project, environment),
|
||||
dataAction: "dashboards-landing",
|
||||
action: (
|
||||
<CreateDashboardButton
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
),
|
||||
after: (
|
||||
<DashboardList
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
isCollapsed={isCollapsed}
|
||||
user={user}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
staticSections.push({
|
||||
id: "deployments",
|
||||
title: "Deployments",
|
||||
items: [
|
||||
{
|
||||
id: "deployments",
|
||||
name: "Deploys",
|
||||
icon: DeploymentsIcon,
|
||||
activeIconColor: "text-deployments",
|
||||
to: v3DeploymentsPath(organization, project, environment),
|
||||
dataAction: "deployments",
|
||||
},
|
||||
{
|
||||
id: "environment-variables",
|
||||
name: "Environment variables",
|
||||
icon: IDIcon,
|
||||
activeIconColor: "text-environmentVariables",
|
||||
to: v3EnvironmentVariablesPath(organization, project, environment),
|
||||
dataAction: "environment variables",
|
||||
},
|
||||
{
|
||||
id: "preview-branches",
|
||||
name: "Preview branches",
|
||||
icon: BranchEnvironmentIconSmall,
|
||||
activeIconColor: "text-previewBranches",
|
||||
to: branchesPath(organization, project, environment),
|
||||
dataAction: "preview-branches",
|
||||
},
|
||||
{
|
||||
id: "regions",
|
||||
name: "Regions",
|
||||
icon: GlobeLinesIcon,
|
||||
activeIconColor: "text-regions",
|
||||
to: regionsPath(organization, project, environment),
|
||||
dataAction: "regions",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
staticSections.push({
|
||||
id: "manage",
|
||||
title: "Manage",
|
||||
items: [
|
||||
{
|
||||
id: "waitpoint-tokens",
|
||||
name: "Waitpoint tokens",
|
||||
icon: WaitpointTokenIcon,
|
||||
activeIconColor: "text-sky-500",
|
||||
to: v3WaitpointTokensPath(organization, project, environment),
|
||||
dataAction: "waitpoint-tokens",
|
||||
},
|
||||
{
|
||||
id: "batches",
|
||||
name: "Batches",
|
||||
icon: BatchesIcon,
|
||||
activeIconColor: "text-batches",
|
||||
to: v3BatchesPath(organization, project, environment),
|
||||
dataAction: "batches",
|
||||
},
|
||||
{
|
||||
id: "bulk-actions",
|
||||
name: "Bulk actions",
|
||||
icon: ListCheckedIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: v3BulkActionsPath(organization, project, environment),
|
||||
dataAction: "bulk actions",
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
name: "API keys",
|
||||
icon: KeyIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: v3ApiKeysPath(organization, project, environment),
|
||||
dataAction: "api keys",
|
||||
},
|
||||
{
|
||||
id: "alerts",
|
||||
name: "Alerts",
|
||||
icon: BellIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: v3ProjectAlertsPath(organization, project, environment),
|
||||
dataAction: "alerts",
|
||||
},
|
||||
...(isManagedCloud
|
||||
? [
|
||||
{
|
||||
id: "concurrency",
|
||||
name: "Concurrency",
|
||||
icon: ConcurrencyIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: concurrencyPath(organization, project, environment),
|
||||
dataAction: "concurrency",
|
||||
} satisfies SideMenuItemConfig,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "limits",
|
||||
name: "Limits",
|
||||
icon: DialIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: limitsPath(organization, project, environment),
|
||||
dataAction: "limits",
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
name: "Integrations",
|
||||
icon: IntegrationsIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: v3ProjectSettingsIntegrationsPath(organization, project, environment),
|
||||
dataAction: "project-settings-integrations",
|
||||
},
|
||||
],
|
||||
const staticSections = buildSideMenuSections({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
isAdmin,
|
||||
featureFlags,
|
||||
isManagedCloud,
|
||||
dashboards: {
|
||||
action: (
|
||||
<CreateDashboardButton
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
),
|
||||
after: (
|
||||
<DashboardList
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
isCollapsed={isCollapsed}
|
||||
user={user}
|
||||
/>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const sideMenuPrefs = user.dashboardPreferences.sideMenu;
|
||||
@@ -1930,6 +1677,7 @@ function AccountMenuItems({
|
||||
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
|
||||
className={SIDE_MENU_POPOVER_ITEM_LABEL}
|
||||
/>
|
||||
<AppearanceMenuItem />
|
||||
<PopoverMenuItem
|
||||
to={personalAccessTokensPath()}
|
||||
title="Personal Access Tokens"
|
||||
@@ -2137,81 +1885,6 @@ function ProjectSelector({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover-expandable submenu row for side-menu popovers (Account, Switch organization, Integrations):
|
||||
* a menu item with a trailing chevron that reveals `children` in a popover to the right, with a
|
||||
* short close delay so the pointer can cross the gap.
|
||||
*/
|
||||
function SideMenuPopoverSubMenu({
|
||||
title,
|
||||
icon,
|
||||
leadingIconClassName,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: RenderIcon;
|
||||
leadingIconClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close the submenu on navigation (the parent popover closes too).
|
||||
useEffect(() => {
|
||||
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
|
||||
setIsOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
const openNow = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setIsOpen(true);
|
||||
};
|
||||
const closeSoon = () => {
|
||||
// Small delay before closing so the pointer can move onto the content.
|
||||
timeoutRef.current = setTimeout(() => setIsOpen(false), 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)} open={isOpen}>
|
||||
<div onMouseEnter={openNow} onMouseLeave={closeSoon} className="flex">
|
||||
<PopoverTrigger className="w-full justify-between overflow-hidden focus-custom">
|
||||
<ButtonContent
|
||||
variant="small-menu-item"
|
||||
className={cn("hover:bg-background-hover", SIDE_MENU_POPOVER_ITEM_LABEL)}
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, leadingIconClassName)}
|
||||
TrailingIcon={ChevronRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
textAlignLeft
|
||||
fullWidth
|
||||
>
|
||||
{title}
|
||||
</ButtonContent>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-64 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
side="right"
|
||||
alignOffset={0}
|
||||
sideOffset={-4}
|
||||
onMouseEnter={openNow}
|
||||
onMouseLeave={closeSoon}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitchOrganizations({
|
||||
organizations,
|
||||
organization,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ButtonContent } from "../primitives/Buttons";
|
||||
import { type RenderIcon } from "../primitives/Icon";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes";
|
||||
|
||||
/**
|
||||
* A menu item whose chevron reveals `children` in a popover to the right, with a
|
||||
* short close delay so the pointer can cross the gap.
|
||||
*/
|
||||
export function SideMenuPopoverSubMenu({
|
||||
title,
|
||||
icon,
|
||||
leadingIconClassName,
|
||||
contentClassName,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: RenderIcon;
|
||||
leadingIconClassName?: string;
|
||||
/** Override the submenu panel's styling, e.g. a narrower width for short entries. */
|
||||
contentClassName?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close the submenu on navigation (the parent popover closes too).
|
||||
useEffect(() => {
|
||||
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
|
||||
setIsOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
const openNow = () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setIsOpen(true);
|
||||
};
|
||||
const closeSoon = () => {
|
||||
// Small delay before closing so the pointer can move onto the content.
|
||||
timeoutRef.current = setTimeout(() => setIsOpen(false), 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsOpen(open)} open={isOpen}>
|
||||
<div onMouseEnter={openNow} onMouseLeave={closeSoon} className="flex">
|
||||
<PopoverTrigger className="w-full justify-between overflow-hidden focus-custom">
|
||||
<ButtonContent
|
||||
variant="small-menu-item"
|
||||
className={cn("hover:bg-background-hover", SIDE_MENU_POPOVER_ITEM_LABEL)}
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={cn(SIDE_MENU_POPOVER_ITEM_ICON, leadingIconClassName)}
|
||||
TrailingIcon={ChevronRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
textAlignLeft
|
||||
fullWidth
|
||||
>
|
||||
{title}
|
||||
</ButtonContent>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn(
|
||||
"min-w-64 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
||||
contentClassName
|
||||
)}
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
side="right"
|
||||
alignOffset={0}
|
||||
sideOffset={-4}
|
||||
onMouseEnter={openNow}
|
||||
onMouseLeave={closeSoon}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { AIPenIcon } from "~/assets/icons/AIPenIcon";
|
||||
import { BatchesIcon } from "~/assets/icons/BatchesIcon";
|
||||
import { BellIcon } from "~/assets/icons/BellIcon";
|
||||
import { Box3DIcon } from "~/assets/icons/Box3DIcon";
|
||||
import { BugIcon } from "~/assets/icons/BugIcon";
|
||||
import { ChartBarIcon } from "~/assets/icons/ChartBarIcon";
|
||||
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon";
|
||||
import { DialIcon } from "~/assets/icons/DialIcon";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon";
|
||||
import { IDIcon } from "~/assets/icons/IDIcon";
|
||||
import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon";
|
||||
import { KeyIcon } from "~/assets/icons/KeyIcon";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { LogsIcon } from "~/assets/icons/LogsIcon";
|
||||
import { QueuesIcon } from "~/assets/icons/QueuesIcon";
|
||||
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
||||
import {
|
||||
type EnvironmentForPath,
|
||||
type OrgForPath,
|
||||
type ProjectForPath,
|
||||
branchesPath,
|
||||
concurrencyPath,
|
||||
limitsPath,
|
||||
queryPath,
|
||||
regionsPath,
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
v3BulkActionsPath,
|
||||
v3DashboardsLandingPath,
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3ErrorsPath,
|
||||
v3LogsPath,
|
||||
v3ModelsPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectSettingsIntegrationsPath,
|
||||
v3PromptsPath,
|
||||
v3QueuesPath,
|
||||
v3WaitpointTokensPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AlphaBadge, NewBadge } from "../FeatureBadges";
|
||||
import { type RenderIcon } from "../primitives/Icon";
|
||||
import { type SideMenuSectionId } from "./sideMenuTypes";
|
||||
|
||||
// The side menu's customizable sections, in DEFAULT order. Outside SideMenu so
|
||||
// the profile page can build the same list without one to read from.
|
||||
|
||||
type SideMenuItemConfig = {
|
||||
/** Stable id used for hidden/order preferences; never rename once shipped. */
|
||||
id: string;
|
||||
name: string;
|
||||
icon: RenderIcon;
|
||||
activeIconColor: string;
|
||||
inactiveIconColor?: string;
|
||||
to: string;
|
||||
dataAction?: string;
|
||||
badge?: ReactNode;
|
||||
trailingIconClassName?: string;
|
||||
/** Hidden for every user who hasn't set their own preference for this item. */
|
||||
defaultHidden?: boolean;
|
||||
/** Right-side action (e.g. the + button on Dashboards); only rendered when visible. */
|
||||
action?: ReactNode;
|
||||
/** Extra content rendered directly after the item (e.g. the dashboards list). */
|
||||
after?: ReactNode;
|
||||
};
|
||||
|
||||
export type SideMenuSectionConfig = {
|
||||
id: SideMenuSectionId;
|
||||
title: string;
|
||||
items: SideMenuItemConfig[];
|
||||
};
|
||||
|
||||
export function buildSideMenuSections({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
isAdmin,
|
||||
featureFlags,
|
||||
isManagedCloud,
|
||||
dashboards,
|
||||
}: {
|
||||
organization: OrgForPath;
|
||||
project: ProjectForPath;
|
||||
environment: EnvironmentForPath;
|
||||
isAdmin: boolean;
|
||||
featureFlags: { hasAiAccess?: boolean; hasQueryAccess?: boolean; hasLogsPageAccess?: boolean };
|
||||
isManagedCloud: boolean;
|
||||
/** Side-menu-only extras on the Dashboards item; the customize modal has no use for them. */
|
||||
dashboards?: { action?: ReactNode; after?: ReactNode };
|
||||
}): SideMenuSectionConfig[] {
|
||||
const staticSections: SideMenuSectionConfig[] = [];
|
||||
|
||||
if (isAdmin || featureFlags.hasAiAccess) {
|
||||
staticSections.push({
|
||||
id: "ai",
|
||||
title: "AI",
|
||||
items: [
|
||||
{
|
||||
id: "prompts",
|
||||
name: "Prompts",
|
||||
icon: AIPenIcon,
|
||||
trailingIconClassName: "size-6",
|
||||
activeIconColor: "text-aiPrompts",
|
||||
to: v3PromptsPath(organization, project, environment),
|
||||
dataAction: "prompts",
|
||||
badge: <NewBadge />,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
name: "Models",
|
||||
icon: Box3DIcon,
|
||||
activeIconColor: "text-models",
|
||||
to: v3ModelsPath(organization, project, environment),
|
||||
dataAction: "models",
|
||||
badge: <NewBadge />,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (isAdmin || featureFlags.hasQueryAccess || featureFlags.hasLogsPageAccess) {
|
||||
staticSections.push({
|
||||
id: "metrics",
|
||||
title: "Observability",
|
||||
items: [
|
||||
...(isAdmin || featureFlags.hasLogsPageAccess
|
||||
? [
|
||||
{
|
||||
id: "logs",
|
||||
name: "Logs",
|
||||
icon: LogsIcon,
|
||||
activeIconColor: "text-logs",
|
||||
to: v3LogsPath(organization, project, environment),
|
||||
dataAction: "logs",
|
||||
badge: <AlphaBadge />,
|
||||
} satisfies SideMenuItemConfig,
|
||||
]
|
||||
: []),
|
||||
...(isAdmin || featureFlags.hasQueryAccess
|
||||
? [
|
||||
{
|
||||
id: "errors",
|
||||
name: "Errors",
|
||||
icon: BugIcon,
|
||||
activeIconColor: "text-errors",
|
||||
to: v3ErrorsPath(organization, project, environment),
|
||||
dataAction: "errors",
|
||||
} satisfies SideMenuItemConfig,
|
||||
{
|
||||
id: "query",
|
||||
name: "Query",
|
||||
icon: CodeSquareIcon,
|
||||
activeIconColor: "text-query",
|
||||
to: queryPath(organization, project, environment),
|
||||
dataAction: "query",
|
||||
} satisfies SideMenuItemConfig,
|
||||
{
|
||||
id: "queues",
|
||||
name: "Queues",
|
||||
icon: QueuesIcon,
|
||||
activeIconColor: "text-queues",
|
||||
to: v3QueuesPath(organization, project, environment),
|
||||
dataAction: "queues",
|
||||
} satisfies SideMenuItemConfig,
|
||||
{
|
||||
id: "dashboards",
|
||||
name: "Dashboards",
|
||||
icon: ChartBarIcon,
|
||||
activeIconColor: "text-metrics",
|
||||
to: v3DashboardsLandingPath(organization, project, environment),
|
||||
dataAction: "dashboards-landing",
|
||||
action: dashboards?.action,
|
||||
after: dashboards?.after,
|
||||
} satisfies SideMenuItemConfig,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
staticSections.push({
|
||||
id: "deployments",
|
||||
title: "Deployments",
|
||||
items: [
|
||||
{
|
||||
id: "deployments",
|
||||
name: "Deploys",
|
||||
icon: DeploymentsIcon,
|
||||
activeIconColor: "text-deployments",
|
||||
to: v3DeploymentsPath(organization, project, environment),
|
||||
dataAction: "deployments",
|
||||
},
|
||||
{
|
||||
id: "environment-variables",
|
||||
name: "Environment variables",
|
||||
icon: IDIcon,
|
||||
activeIconColor: "text-environmentVariables",
|
||||
to: v3EnvironmentVariablesPath(organization, project, environment),
|
||||
dataAction: "environment variables",
|
||||
},
|
||||
{
|
||||
id: "preview-branches",
|
||||
name: "Preview branches",
|
||||
icon: BranchEnvironmentIconSmall,
|
||||
activeIconColor: "text-previewBranches",
|
||||
to: branchesPath(organization, project, environment),
|
||||
dataAction: "preview-branches",
|
||||
},
|
||||
{
|
||||
id: "regions",
|
||||
name: "Regions",
|
||||
icon: GlobeLinesIcon,
|
||||
activeIconColor: "text-regions",
|
||||
to: regionsPath(organization, project, environment),
|
||||
dataAction: "regions",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
staticSections.push({
|
||||
id: "manage",
|
||||
title: "Manage",
|
||||
items: [
|
||||
{
|
||||
id: "waitpoint-tokens",
|
||||
name: "Waitpoint tokens",
|
||||
icon: WaitpointTokenIcon,
|
||||
activeIconColor: "text-sky-500",
|
||||
to: v3WaitpointTokensPath(organization, project, environment),
|
||||
dataAction: "waitpoint-tokens",
|
||||
},
|
||||
{
|
||||
id: "batches",
|
||||
name: "Batches",
|
||||
icon: BatchesIcon,
|
||||
activeIconColor: "text-batches",
|
||||
to: v3BatchesPath(organization, project, environment),
|
||||
dataAction: "batches",
|
||||
},
|
||||
{
|
||||
id: "bulk-actions",
|
||||
name: "Bulk actions",
|
||||
icon: ListCheckedIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: v3BulkActionsPath(organization, project, environment),
|
||||
dataAction: "bulk actions",
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
name: "API keys",
|
||||
icon: KeyIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: v3ApiKeysPath(organization, project, environment),
|
||||
dataAction: "api keys",
|
||||
},
|
||||
{
|
||||
id: "alerts",
|
||||
name: "Alerts",
|
||||
icon: BellIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: v3ProjectAlertsPath(organization, project, environment),
|
||||
dataAction: "alerts",
|
||||
},
|
||||
...(isManagedCloud
|
||||
? [
|
||||
{
|
||||
id: "concurrency",
|
||||
name: "Concurrency",
|
||||
icon: ConcurrencyIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: concurrencyPath(organization, project, environment),
|
||||
dataAction: "concurrency",
|
||||
} satisfies SideMenuItemConfig,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "limits",
|
||||
name: "Limits",
|
||||
icon: DialIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: limitsPath(organization, project, environment),
|
||||
dataAction: "limits",
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
name: "Integrations",
|
||||
icon: IntegrationsIcon,
|
||||
activeIconColor: "text-text-bright",
|
||||
to: v3ProjectSettingsIntegrationsPath(organization, project, environment),
|
||||
dataAction: "project-settings-integrations",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return staticSections;
|
||||
}
|
||||
@@ -118,14 +118,31 @@ function styleFromSize(size: number) {
|
||||
};
|
||||
}
|
||||
|
||||
// Bright tiles (Yellow, Orange) need dark letters for contrast; the rest read
|
||||
// best with white.
|
||||
const AVATAR_LETTER_DARK = "#272a2e";
|
||||
const AVATAR_LETTER_LIGHT = "#ffffff";
|
||||
|
||||
/** WCAG relative luminance, so the pair below compares the way a checker would. */
|
||||
function relativeLuminance(r: number, g: number, b: number): number {
|
||||
const channel = (value: number) => {
|
||||
const c = value / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
||||
}
|
||||
|
||||
function contrastRatio(a: number, b: number): number {
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
/** Dark or white initials, whichever holds more contrast against the tile. */
|
||||
function letterColorForBackground(hex: string): string {
|
||||
const match = /^#?([0-9a-f]{6})$/i.exec(hex);
|
||||
if (!match) return "#fff";
|
||||
if (!match) return AVATAR_LETTER_LIGHT;
|
||||
const n = parseInt(match[1], 16);
|
||||
const luminance = 0.299 * ((n >> 16) & 255) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255);
|
||||
return luminance > 140 ? "#272A2E" : "#fff";
|
||||
const tile = relativeLuminance((n >> 16) & 255, (n >> 8) & 255, n & 255);
|
||||
const onDark = contrastRatio(tile, relativeLuminance(39, 42, 46));
|
||||
const onLight = contrastRatio(tile, 1);
|
||||
return onDark >= onLight ? AVATAR_LETTER_DARK : AVATAR_LETTER_LIGHT;
|
||||
}
|
||||
|
||||
function AvatarLetters({
|
||||
|
||||
@@ -3,15 +3,16 @@ import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
default:
|
||||
"grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-background-hover text-text-bright uppercase whitespace-nowrap",
|
||||
"grid place-items-center rounded-full px-2 h-5 tracking-wider text-xxs bg-background-hover text-text-bright system:bg-charcoal-500 system:text-white uppercase whitespace-nowrap",
|
||||
"extra-small":
|
||||
"grid place-items-center border border-border-bright rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500/10 system:text-blue-500 whitespace-nowrap",
|
||||
"grid place-items-center border border-border-bright rounded-sm px-1 h-4 text-xxs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500 system:text-white whitespace-nowrap",
|
||||
small:
|
||||
"grid place-items-center border border-border-bright rounded-sm px-1 h-5 text-xs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500/10 system:text-blue-500 whitespace-nowrap",
|
||||
"grid place-items-center border border-border-bright rounded-sm px-1 h-5 text-xs bg-background-bright text-blue-500 system:border-transparent system:bg-blue-500 system:text-white whitespace-nowrap",
|
||||
"outline-rounded":
|
||||
"grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 uppercase whitespace-nowrap",
|
||||
"grid place-items-center rounded-full px-1 h-4 tracking-wider text-xxs border border-blue-500 text-blue-500 system:border-transparent system:bg-blue-500 system:text-white uppercase whitespace-nowrap",
|
||||
// White, not text-text-bright: the fill is the same in every theme.
|
||||
rounded:
|
||||
"grid place-items-center rounded-full px-1.5 h-4 text-xxs border bg-blue-600 text-text-bright system:border-transparent system:text-white uppercase whitespace-nowrap",
|
||||
"grid place-items-center rounded-full px-1.5 h-4 text-xxs border bg-blue-600 text-white system:border-transparent uppercase whitespace-nowrap",
|
||||
};
|
||||
|
||||
type BadgeProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
|
||||
@@ -61,7 +61,8 @@ const theme = {
|
||||
primary: {
|
||||
textColor: "text-white transition group-disabled/button:text-white/60",
|
||||
button:
|
||||
"bg-indigo-600 border border-indigo-500 group-hover/button:bg-indigo-500 group-hover/button:border-indigo-400 group-disabled/button:opacity-50 group-disabled/button:bg-indigo-600 group-disabled/button:border-indigo-500 group-disabled/button:pointer-events-none",
|
||||
// Shares --color-accent-fill with the switch's checked track.
|
||||
"bg-accent-fill border border-lavender-500 group-hover/button:bg-lavender-500 group-hover/button:border-lavender-400 group-disabled/button:opacity-50 group-disabled/button:bg-accent-fill group-disabled/button:border-lavender-500 group-disabled/button:pointer-events-none",
|
||||
shortcut:
|
||||
"border-white/40 text-white group-hover/button:border-white/60 group-hover/button:text-white",
|
||||
icon: "text-white",
|
||||
@@ -69,7 +70,8 @@ const theme = {
|
||||
secondary: {
|
||||
textColor: "text-text-bright transition group-disabled/button:text-text-dimmed/80",
|
||||
button:
|
||||
"bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
|
||||
// Hover steps up the scale; background-raised would be darker.
|
||||
"bg-secondary border border-border-bright/50 shadow-xs group-hover/button:bg-background-raised dark:group-hover/button:bg-surface-control group-disabled/button:bg-secondary group-disabled/button:opacity-60 group-disabled/button:pointer-events-none",
|
||||
shortcut:
|
||||
"border-text-dimmed/40 text-text-dimmed group-hover/button:text-text-bright group-hover/button:border-text-dimmed",
|
||||
icon: "text-text-bright",
|
||||
@@ -296,6 +298,9 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
className={cn(
|
||||
textAlignLeft ? "text-left" : "justify-center",
|
||||
"flex w-full items-center",
|
||||
// Set here, not on the string branch below, so element children
|
||||
// inherit the variant's colour rather than the page's.
|
||||
textColorClassName,
|
||||
iconSpacingClassName,
|
||||
iconSpacing,
|
||||
showSpinner && "invisible"
|
||||
@@ -315,9 +320,7 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
|
||||
{text &&
|
||||
(typeof text === "string" ? (
|
||||
<span className={cn("mx-auto grow self-center truncate", textColorClassName)}>
|
||||
{text}
|
||||
</span>
|
||||
<span className="mx-auto grow self-center truncate">{text}</span>
|
||||
) : (
|
||||
text
|
||||
))}
|
||||
@@ -345,8 +348,11 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
renderShortcutKey()}
|
||||
</div>
|
||||
{showSpinner && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="size-3.5" color="white" />
|
||||
// Inherits the variant's colour so the spinner tracks its button.
|
||||
<span
|
||||
className={cn("absolute inset-0 flex items-center justify-center", textColorClassName)}
|
||||
>
|
||||
<Spinner className="size-3.5" color="inherit" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -103,8 +103,10 @@ export function Calendar({
|
||||
),
|
||||
range_start: "day-range-start rounded-l-md",
|
||||
range_end: "day-range-end rounded-r-md",
|
||||
// White rather than text-text-bright: that token is near-black on the
|
||||
// light themes, which put the selected day at 2.66:1 on indigo-600.
|
||||
selected:
|
||||
"bg-indigo-600 text-text-bright hover:bg-indigo-600 hover:text-text-bright focus:bg-indigo-600 focus:text-text-bright rounded-md",
|
||||
"bg-indigo-600 text-white hover:bg-indigo-600 hover:text-white focus:bg-indigo-600 focus:text-white rounded-md",
|
||||
today: "bg-background-raised text-text-bright rounded-md",
|
||||
outside:
|
||||
"day-outside text-text-dimmed opacity-50 aria-selected:bg-background-raised/50 aria-selected:text-text-dimmed aria-selected:opacity-30",
|
||||
|
||||
@@ -104,6 +104,9 @@ export function CopyButton({
|
||||
return (
|
||||
<span className={className}>
|
||||
<SimpleTooltip
|
||||
// The icon button is a real <button>; without asChild the tooltip
|
||||
// trigger wraps it in its own, and the browser parser splits the nested
|
||||
// buttons apart, which React then fails to hydrate.
|
||||
asChild
|
||||
tabbable
|
||||
button={iconButton}
|
||||
|
||||
@@ -9,15 +9,17 @@ export function PulsingDot({
|
||||
ringClassName?: string;
|
||||
dotClassName?: string;
|
||||
}) {
|
||||
/* The dot fills the container, so resizing the whole thing scales the dot and
|
||||
the ping ring together. */
|
||||
return (
|
||||
<span className={cn("relative flex size-2", className)}>
|
||||
<span className={cn("relative flex size-2 items-center justify-center", className)}>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute h-full w-full animate-ping rounded-full border border-blue-500 opacity-100 duration-1000",
|
||||
ringClassName
|
||||
)}
|
||||
/>
|
||||
<span className={cn("size-2 rounded-full bg-blue-500", dotClassName)} />
|
||||
<span className={cn("size-full rounded-full bg-blue-500", dotClassName)} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const theme = {
|
||||
selected: "absolute inset-0 rounded-[2px] outline-solid outline-3 outline-primary",
|
||||
},
|
||||
secondary: {
|
||||
base: "bg-transparent dark:bg-background-raised/50",
|
||||
base: "bg-segmented-track",
|
||||
active: "text-text-bright",
|
||||
inactive: "text-text-dimmed transition hover:text-text-bright",
|
||||
selected:
|
||||
|
||||
@@ -30,8 +30,9 @@ const style = {
|
||||
"bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
|
||||
},
|
||||
secondary: {
|
||||
// Matches the secondary button's hover.
|
||||
button:
|
||||
"bg-secondary focus-custom border border-border-bright/50 shadow-xs hover:text-text-bright text-text-bright hover:bg-background-raised",
|
||||
"bg-secondary focus-custom border border-border-bright/50 shadow-xs text-text-bright hover:bg-background-raised dark:hover:bg-surface-control",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -355,8 +356,9 @@ export function SelectTrigger({
|
||||
</div>
|
||||
{dropdownIcon === true ? (
|
||||
<ChevronDown
|
||||
// No transition: the trigger's hover is instant
|
||||
className={cn(
|
||||
"size-4 flex-none text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
|
||||
"size-4 flex-none text-text-dimmed group-hover:text-text-bright group-focus:text-text-bright"
|
||||
)}
|
||||
/>
|
||||
) : !dropdownIcon ? null : (
|
||||
|
||||
@@ -40,6 +40,13 @@ export function SettingsContainer({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For pages that hand-roll their sections. Must stay in step with the `mt-13` in
|
||||
* `SettingsSection`: Tailwind only generates classes written as full literals,
|
||||
* so the two can't be composed.
|
||||
*/
|
||||
export const SETTINGS_SECTION_GAP = "mt-13";
|
||||
|
||||
/** A group of related rows. Adds vertical spacing between sibling sections. */
|
||||
export function SettingsSection({
|
||||
children,
|
||||
@@ -49,7 +56,7 @@ export function SettingsSection({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("w-full [&:not(:first-child)]:mt-12", className)}>{children}</section>
|
||||
<section className={cn("w-full [&:not(:first-child)]:mt-13", className)}>{children}</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,7 +119,6 @@ export function SettingsRowTitle({
|
||||
);
|
||||
}
|
||||
|
||||
/** Description/subtitle typography for a row. */
|
||||
export function SettingsRowDescription({
|
||||
children,
|
||||
className,
|
||||
@@ -127,6 +133,9 @@ export function SettingsRowDescription({
|
||||
);
|
||||
}
|
||||
|
||||
/** Title-to-description spacing, shared with anything hand-rolling the pair. */
|
||||
export const SETTINGS_ROW_TITLE_GAP = "space-y-0.5";
|
||||
|
||||
/**
|
||||
* A single settings row: title + description on the left, action on the right.
|
||||
*
|
||||
@@ -168,7 +177,7 @@ export function SettingsRow({
|
||||
)}
|
||||
>
|
||||
{children ?? (
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<div className={cn("flex-1", SETTINGS_ROW_TITLE_GAP)}>
|
||||
{title ? (
|
||||
<SettingsRowTitle htmlFor={htmlFor} className={titleClassName}>
|
||||
{title}
|
||||
@@ -227,7 +236,7 @@ export function SettingsAlertRow({
|
||||
|
||||
return (
|
||||
<SettingsRow action={action}>
|
||||
<div className="flex-1 space-y-0.5">
|
||||
<div className={cn("flex-1", SETTINGS_ROW_TITLE_GAP)}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Icon className={cn("size-4 shrink-0", color)} />
|
||||
<SettingsRowTitle className={color}>{title}</SettingsRowTitle>
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import * as RadixSlider from "@radix-ui/react-slider";
|
||||
import type { ComponentProps } from "react";
|
||||
import { type ComponentProps, useEffect, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import type { RenderIcon } from "./Icon";
|
||||
import { Icon } from "./Icon";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
|
||||
const variants = {
|
||||
/* Quiet variant for settings rows: no hover box, no thumb halo */
|
||||
settings: {
|
||||
container: "h-6 gap-1 rounded-sm px-1",
|
||||
icons: "h-4 w-4 text-text-bright",
|
||||
root: "h-4 grow",
|
||||
track: "h-1 bg-grid-bright",
|
||||
range: "bg-transparent",
|
||||
// Matches the Switch thumb; the secondary-button hairline+shadow keeps the
|
||||
// white dot visible on the light track
|
||||
// #f4f4f6 (white/charcoal-100 midpoint) is opaque on purpose: a half-alpha
|
||||
// hover would let the track line show through the handle.
|
||||
thumb:
|
||||
"h-3 w-3 border border-border-bright bg-white shadow-sm dark:border-transparent dark:bg-charcoal-200 dark:shadow-none",
|
||||
"h-4.5 w-4.5 border border-border-bright bg-white shadow-sm hover:bg-[#f4f4f6] dark:border-transparent dark:bg-charcoal-300 dark:shadow-none dark:hover:bg-charcoal-200",
|
||||
thumbSize: 18,
|
||||
mark: "bg-grid-bright border-background-dimmed",
|
||||
markHover: "hover:bg-text-dimmed",
|
||||
},
|
||||
tertiary: {
|
||||
container: "h-6 gap-1 rounded-sm hover:bg-background-raised px-1",
|
||||
@@ -23,8 +26,10 @@ const variants = {
|
||||
root: "h-4",
|
||||
track: "h-1 bg-grid-bright group-hover:bg-background-dimmed",
|
||||
range: "bg-transparent group-hover:bg-secondary",
|
||||
thumb:
|
||||
"h-3 w-3 border-2 border-text-dimmed bg-grid-bright shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)] hover:border-text-dimmed focus:shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)]",
|
||||
thumb: "h-3 w-3 border-2 border-text-dimmed bg-grid-bright",
|
||||
thumbSize: 12,
|
||||
mark: "bg-grid-bright border-background-dimmed",
|
||||
markHover: "hover:bg-text-dimmed",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,6 +39,18 @@ export type SliderProps = ComponentProps<typeof RadixSlider.Root> & {
|
||||
LeadingIcon?: RenderIcon;
|
||||
TrailingIcon?: RenderIcon;
|
||||
variant: VariantName;
|
||||
/** Label above the thumb while hovered or dragged. Reads `value`, so pass one. */
|
||||
valueTooltip?: (value: number) => string;
|
||||
/** Values to tick on the track, e.g. the setting's default. */
|
||||
marks?: SliderMark[];
|
||||
};
|
||||
|
||||
type SliderMark = {
|
||||
value: number;
|
||||
/** Tooltip on hover, and the accessible name once `onSelect` is set. */
|
||||
label?: string;
|
||||
/** Makes the mark a button, e.g. to reset the setting to its default. */
|
||||
onSelect?: () => void;
|
||||
};
|
||||
|
||||
export function Slider({
|
||||
@@ -42,11 +59,35 @@ export function Slider({
|
||||
LeadingIcon,
|
||||
TrailingIcon,
|
||||
"aria-label": ariaLabel,
|
||||
valueTooltip,
|
||||
marks,
|
||||
...props
|
||||
}: SliderProps) {
|
||||
const variation = variants[variant];
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
/* Root's onPointerUp only fires if the release lands back on the slider, so
|
||||
watch the window while a drag is in progress. */
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const stop = () => setIsDragging(false);
|
||||
window.addEventListener("pointerup", stop);
|
||||
window.addEventListener("pointercancel", stop);
|
||||
return () => {
|
||||
window.removeEventListener("pointerup", stop);
|
||||
window.removeEventListener("pointercancel", stop);
|
||||
};
|
||||
}, [isDragging]);
|
||||
const currentValue = props.value?.[0] ?? props.defaultValue?.[0] ?? 0;
|
||||
const min = props.min ?? 0;
|
||||
const max = props.max ?? 100;
|
||||
|
||||
return (
|
||||
<div className={cn("group flex items-center", variation.container)}>
|
||||
<div
|
||||
className={cn("group flex items-center", variation.container)}
|
||||
// Radix holds pointer capture mid-drag, so this can't fire during one.
|
||||
onPointerLeave={() => setIsDragging(false)}
|
||||
>
|
||||
{LeadingIcon && <Icon icon={LeadingIcon} className={variation.icons} />}
|
||||
<RadixSlider.Root
|
||||
className={cn(
|
||||
@@ -55,18 +96,87 @@ export function Slider({
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onPointerDown={(event) => {
|
||||
props.onPointerDown?.(event);
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
props.onPointerUp?.(event);
|
||||
setIsDragging(false);
|
||||
}}
|
||||
onPointerCancel={(event) => {
|
||||
props.onPointerCancel?.(event);
|
||||
setIsDragging(false);
|
||||
}}
|
||||
>
|
||||
<RadixSlider.Track className={cn("relative grow rounded-full", variation.track)}>
|
||||
<RadixSlider.Range className={cn("absolute h-full rounded-full", variation.range)} />
|
||||
</RadixSlider.Track>
|
||||
{/* The thumb is the role="slider" element, so the label lives here */}
|
||||
{marks?.map((mark) => {
|
||||
const percent = ((mark.value - min) / (max - min)) * 100;
|
||||
if (!Number.isFinite(percent) || percent < 0 || percent > 100) return null;
|
||||
// Same formula as Radix's `getThumbInBoundsOffset`, so the tick lands
|
||||
// under the thumb's centre.
|
||||
const offset = variation.thumbSize * (0.5 - percent / 100);
|
||||
const style = { left: `calc(${percent}% + ${offset}px)` };
|
||||
// box-content hangs the borders outside the 2px line, so they read as
|
||||
// a gap in the track rather than eating it.
|
||||
const markClassName = cn(
|
||||
"absolute top-1/2 box-content h-4 w-0.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-x-[3px]",
|
||||
variation.mark
|
||||
);
|
||||
|
||||
if (!mark.onSelect) {
|
||||
return <span key={mark.value} aria-hidden className={markClassName} style={style} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
key={mark.value}
|
||||
asChild
|
||||
tabbable
|
||||
disableHoverableContent
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="px-2 py-1.5 text-xs"
|
||||
content={mark.label}
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={mark.label}
|
||||
className={cn(markClassName, "cursor-pointer", variation.markHover)}
|
||||
style={style}
|
||||
// Or Radix reads the press as a track click and drags the thumb.
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={mark.onSelect}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<RadixSlider.Thumb
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"block cursor-pointer rounded-full transition focus:outline-hidden",
|
||||
"group/thumb relative block cursor-pointer rounded-full transition focus:outline-hidden",
|
||||
variation.thumb
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{valueTooltip && (
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute bottom-full left-1/2 mb-2.5 -translate-x-1/2 rounded border border-grid-bright bg-background-bright px-1.5 py-0.5 text-xs tabular-nums text-text-bright shadow-md transition-opacity",
|
||||
// focus-visible, not focus: a click leaves the thumb focused,
|
||||
// which would strand the label on screen.
|
||||
isDragging
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/thumb:opacity-100 group-focus-visible/thumb:opacity-100"
|
||||
)}
|
||||
>
|
||||
{valueTooltip(currentValue)}
|
||||
<span className="absolute left-1/2 top-full size-2 -translate-x-1/2 -translate-y-1/2 rotate-45 border-b border-r border-grid-bright bg-background-bright" />
|
||||
</span>
|
||||
)}
|
||||
</RadixSlider.Thumb>
|
||||
</RadixSlider.Root>
|
||||
{TrailingIcon && <Icon icon={TrailingIcon} className={variation.icons} />}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export function Spinner({
|
||||
color = "blue",
|
||||
}: {
|
||||
className?: string;
|
||||
color?: "blue" | "white" | "muted" | "dark" | CustomColor;
|
||||
color?: "blue" | "white" | "muted" | "dark" | "inherit" | CustomColor;
|
||||
}) {
|
||||
const colors = {
|
||||
blue: {
|
||||
@@ -22,13 +22,22 @@ export function Spinner({
|
||||
background: "rgba(255, 255, 255, 0.4)",
|
||||
foreground: "rgba(255, 255, 255)",
|
||||
},
|
||||
/* Theme tokens rather than fixed values, so a muted spinner stays muted
|
||||
against a light surface instead of staying dark-theme navy. */
|
||||
muted: {
|
||||
background: "#1C2433",
|
||||
foreground: "#3C4B62",
|
||||
background: "var(--color-grid-bright)",
|
||||
foreground: "var(--color-text-dimmed)",
|
||||
},
|
||||
dark: {
|
||||
background: "rgba(18, 19, 23, 0.35)",
|
||||
foreground: "#1A1B1F",
|
||||
background: "color-mix(in srgb, var(--color-charcoal-900) 35%, transparent)",
|
||||
foreground: "var(--color-charcoal-900)",
|
||||
},
|
||||
/* Takes the surrounding text color, so it follows both the theme and
|
||||
whatever it sits on - white on a primary button, dark ink on a
|
||||
secondary one once the theme is light. */
|
||||
inherit: {
|
||||
background: "color-mix(in srgb, currentColor 40%, transparent)",
|
||||
foreground: "currentColor",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,18 @@ import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
|
||||
/*
|
||||
The thumb sits inside the track's 2px transparent border, so an even gap all
|
||||
round means thumb height == the content box and a travel of (width - thumb).
|
||||
The h-4 w-7.5 track is a 12x26 box, hence a 12px thumb travelling 14px. The
|
||||
press squish widens the thumb and takes the same off the travel, +4/-4, so the
|
||||
leading edge stays pinned. `small` still has the original uneven gap.
|
||||
*/
|
||||
const MEDIUM_THUMB = cn(
|
||||
"h-3 w-3 data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0",
|
||||
"group-active:w-4 group-active:data-[state=checked]:translate-x-2.5"
|
||||
);
|
||||
|
||||
const small = {
|
||||
container:
|
||||
"flex items-center h-6 gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 hover:disabled:bg-background-raised transition focus-custom disabled:opacity-50 text-text-dimmed hover:text-text-bright disabled:hover:cursor-not-allowed hover:cursor-pointer disabled:hover:text-rose-500",
|
||||
@@ -55,10 +67,14 @@ const variations = {
|
||||
container:
|
||||
"flex items-center gap-x-2 rounded-md hover:bg-tertiary py-1.5 px-2 transition focus-custom",
|
||||
root: "h-4 w-7.5",
|
||||
thumb: cn(
|
||||
"h-3.5 w-3.5 data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0",
|
||||
"group-active:w-4.5 group-active:data-[state=checked]:translate-x-2"
|
||||
),
|
||||
thumb: MEDIUM_THUMB,
|
||||
text: "text-sm text-text-dimmed",
|
||||
},
|
||||
/* Medium without the hover box, for rows that carry their own affordance. */
|
||||
"minimal/medium": {
|
||||
container: "flex items-center gap-x-2 rounded-md focus-custom",
|
||||
root: "h-4 w-7.5",
|
||||
thumb: MEDIUM_THUMB,
|
||||
text: "text-sm text-text-dimmed",
|
||||
},
|
||||
};
|
||||
@@ -98,14 +114,17 @@ export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.
|
||||
const switchElement = (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-blue-500 group-data-[state=unchecked]:bg-background-raised group-hover:group-data-[state=unchecked]:bg-surface-control-active/50",
|
||||
// Shares --color-accent-fill with the primary button.
|
||||
"inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors group-disabled:cursor-not-allowed group-disabled:opacity-50 group-data-[state=checked]:bg-accent-fill group-data-[state=unchecked]:bg-background-raised group-hover:group-data-[state=unchecked]:bg-surface-control-active/50",
|
||||
root
|
||||
)}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
thumb,
|
||||
"pointer-events-none block rounded-full bg-white transition-[translate,width,background-color] dark:bg-charcoal-200 dark:group-data-[state=checked]:bg-text-bright"
|
||||
/* White in every theme once checked: an off-white thumb was 2.80:1
|
||||
against the fill, under the 3:1 a control's parts need. */
|
||||
"pointer-events-none block rounded-full bg-white transition-[translate,width,background-color] dark:bg-charcoal-200 dark:group-data-[state=checked]:bg-white"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@ export const TITLE_BAR_CHROME = "flex h-10 shrink-0 gap-x-6 border-b border-grid
|
||||
const titleTabLabel = cn(headerVariants.header2.text, "transition duration-200");
|
||||
const titleTabIndicator = "h-0.5 w-full bg-indigo-500";
|
||||
const titleTabIndicatorIdle =
|
||||
"h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100";
|
||||
"h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover/tab:opacity-100";
|
||||
|
||||
export type TabsProps = {
|
||||
tabs: {
|
||||
@@ -98,7 +98,7 @@ function TabLink({
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className="group relative flex h-full grow items-center justify-center focus-custom"
|
||||
className="group/tab relative flex h-full grow items-center justify-center focus-custom"
|
||||
end={end}
|
||||
>
|
||||
{({ isActive, isPending }) => {
|
||||
@@ -111,7 +111,7 @@ function TabLink({
|
||||
"text-sm transition duration-200",
|
||||
active
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed transition group-hover:text-text-bright"
|
||||
: "text-text-dimmed transition group-hover/tab:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -133,7 +133,7 @@ function TabLink({
|
||||
|
||||
if (variant === "title") {
|
||||
return (
|
||||
<NavLink to={to} className="group flex h-full flex-col focus-custom" end={end}>
|
||||
<NavLink to={to} className="group/tab flex h-full flex-col focus-custom" end={end}>
|
||||
{({ isActive, isPending }) => {
|
||||
const active = isActive || isPending;
|
||||
return (
|
||||
@@ -142,7 +142,9 @@ function TabLink({
|
||||
<span
|
||||
className={cn(
|
||||
titleTabLabel,
|
||||
active ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
|
||||
active
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed group-hover/tab:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -190,7 +192,7 @@ function TabLink({
|
||||
|
||||
// underline variant (default)
|
||||
return (
|
||||
<NavLink to={to} className="group flex flex-col items-center pt-1 focus-custom" end={end}>
|
||||
<NavLink to={to} className="group/tab flex flex-col items-center pt-1 focus-custom" end={end}>
|
||||
{({ isActive, isPending }) => {
|
||||
return (
|
||||
<>
|
||||
@@ -211,7 +213,7 @@ function TabLink({
|
||||
className="mt-1 h-0.5 w-full bg-indigo-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="mt-1 h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100" />
|
||||
<div className="mt-1 h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover/tab:opacity-100" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -225,12 +227,15 @@ export function TabButton({
|
||||
layoutId,
|
||||
shortcut,
|
||||
variant = "underline",
|
||||
size = "base",
|
||||
...props
|
||||
}: {
|
||||
isActive: boolean;
|
||||
shortcut?: ShortcutDefinition;
|
||||
layoutId: string;
|
||||
variant?: Variants;
|
||||
/** `"small"` drops the title variant's label to body size. Layout is unchanged. */
|
||||
size?: "base" | "small";
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -249,7 +254,7 @@ export function TabButton({
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"group flex flex-col items-center focus-custom",
|
||||
"group/tab flex flex-col items-center focus-custom",
|
||||
title ? "h-full" : "pt-1",
|
||||
props.className,
|
||||
props.disabled && "pointer-events-none opacity-50"
|
||||
@@ -264,8 +269,12 @@ export function TabButton({
|
||||
"transition duration-200",
|
||||
title
|
||||
? cn(
|
||||
headerVariants.header2.text,
|
||||
isActive ? "text-text-bright" : "text-text-dimmed group-hover:text-text-bright"
|
||||
size === "small"
|
||||
? "font-sans text-sm font-normal leading-5"
|
||||
: headerVariants.header2.text,
|
||||
isActive
|
||||
? "text-text-bright"
|
||||
: "text-text-dimmed group-hover/tab:text-text-bright"
|
||||
)
|
||||
: "text-sm text-text-bright"
|
||||
)}
|
||||
@@ -283,7 +292,7 @@ export function TabButton({
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover:opacity-100",
|
||||
"h-0.5 w-full bg-surface-control-active opacity-0 transition duration-200 group-hover/tab:opacity-100",
|
||||
!title && "mt-1"
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -6,15 +6,26 @@ import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKey
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
|
||||
|
||||
/* Hover shifts colour, never adds an underline: that's reserved for the
|
||||
"Underline links" preference, so its presence always means the preference. */
|
||||
const colors = {
|
||||
primary: "text-text-link transition hover:text-text-link-hover",
|
||||
secondary: "text-text-dimmed transition hover:text-text-bright",
|
||||
} as const;
|
||||
|
||||
const layout = "inline-flex gap-0.5 items-center group";
|
||||
|
||||
/**
|
||||
* A link's colour plus the `inline-text-link` marker, without the layout - for
|
||||
* links that must stay in the inline flow, and triggers that aren't anchors.
|
||||
*/
|
||||
export function textLinkClassName(variant: keyof typeof colors = "primary") {
|
||||
return cn("inline-text-link focus-visible:focus-custom", colors[variant]);
|
||||
}
|
||||
|
||||
const variations = {
|
||||
primary:
|
||||
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom",
|
||||
secondary:
|
||||
"text-text-dimmed transition hover:text-text-bright inline-flex gap-0.5 items-center group focus-visible:focus-custom",
|
||||
// The theme-remapped link token, for links inside themed surfaces where the
|
||||
// raw indigo of `primary` is dark-theme only.
|
||||
token:
|
||||
"text-text-link transition hover:underline inline-flex gap-0.5 items-center group focus-visible:focus-custom",
|
||||
primary: cn(textLinkClassName("primary"), layout),
|
||||
secondary: cn(textLinkClassName("secondary"), layout),
|
||||
} as const;
|
||||
|
||||
type TextLinkProps = {
|
||||
@@ -28,6 +39,8 @@ type TextLinkProps = {
|
||||
shortcut?: ShortcutDefinition;
|
||||
hideShortcutKey?: boolean;
|
||||
tooltip?: React.ReactNode;
|
||||
/** Forwarded to `Link`: forces a full document load rather than a client nav. */
|
||||
reloadDocument?: boolean;
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
export function TextLink({
|
||||
@@ -41,6 +54,7 @@ export function TextLink({
|
||||
shortcut,
|
||||
hideShortcutKey,
|
||||
tooltip,
|
||||
reloadDocument,
|
||||
...props
|
||||
}: TextLinkProps) {
|
||||
const innerRef = useRef<HTMLAnchorElement>(null);
|
||||
@@ -68,7 +82,13 @@ export function TextLink({
|
||||
);
|
||||
|
||||
const linkElement = to ? (
|
||||
<Link ref={innerRef} to={to} className={cn(classes, className)} {...props}>
|
||||
<Link
|
||||
ref={innerRef}
|
||||
to={to}
|
||||
reloadDocument={reloadDocument}
|
||||
className={cn(classes, className)}
|
||||
{...props}
|
||||
>
|
||||
{linkContent}
|
||||
</Link>
|
||||
) : href ? (
|
||||
|
||||
@@ -5,9 +5,9 @@ import { AnimatedNumber } from "../AnimatedNumber";
|
||||
import TooltipPortal from "../TooltipPortal";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
// dark covers Classic too; :is() keeps it one scoped selector when the prefix is
|
||||
// interpolated as `${prefix} [data-chart=...]` below (a comma would break scoping).
|
||||
const THEMES = { light: "", dark: ':is([data-theme="dark"], [data-theme="classic"])' } as const;
|
||||
// :is() keeps this one selector once interpolated below; a comma would break
|
||||
// scoping. `light` is unscoped on purpose: it's the base the dark rule overrides.
|
||||
const THEMES = { light: "", dark: ':is([data-theme="dark"], [data-theme="black"])' } as const;
|
||||
|
||||
export type ChartState = "loading" | "noData" | "invalid" | "loaded" | undefined;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Dialog, DialogContent, DialogHeader } from "../Dialog";
|
||||
import { TITLE_BAR_CHROME } from "../Tabs";
|
||||
import { Card } from "./Card";
|
||||
import { ChartSyncProvider, useChartSync } from "./ChartSyncContext";
|
||||
|
||||
@@ -20,6 +21,12 @@ type ChartCardProps = {
|
||||
maximizable?: boolean;
|
||||
/** Extra classes for the inner Card. */
|
||||
className?: string;
|
||||
/**
|
||||
* `"tabs"` renders the title as a full-width bar the height of a filter bar,
|
||||
* with the divider and the tabs' underlines meeting at its bottom edge. Pass
|
||||
* the tab buttons as `title`; the bar itself is supplied here.
|
||||
*/
|
||||
headerVariant?: "default" | "tabs";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -32,6 +39,7 @@ export function ChartCard({
|
||||
fullscreenChildren,
|
||||
maximizable = true,
|
||||
className,
|
||||
headerVariant = "default",
|
||||
}: ChartCardProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -51,38 +59,47 @@ export function ChartCard({
|
||||
disabled: !maximizable,
|
||||
});
|
||||
|
||||
const tabbed = headerVariant === "tabs";
|
||||
|
||||
const maximizeButton = (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={Maximize2}
|
||||
aria-label="Maximize chart"
|
||||
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="px-1!"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Maximize
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
|
||||
</span>
|
||||
}
|
||||
asChild
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="group h-full min-h-0 overflow-hidden">
|
||||
<Card className={cn("h-full overflow-hidden px-0 pb-2 pt-3", className)}>
|
||||
<Card.Header>
|
||||
<div className="flex items-center gap-1.5">{title}</div>
|
||||
{maximizable && (
|
||||
<Card.Accessory>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span className="opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={Maximize2}
|
||||
aria-label="Maximize chart"
|
||||
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="px-1!"
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Maximize
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="small/bright" />
|
||||
</span>
|
||||
}
|
||||
asChild
|
||||
/>
|
||||
</Card.Accessory>
|
||||
)}
|
||||
</Card.Header>
|
||||
<div className="min-h-0 flex-1 px-2">{children}</div>
|
||||
<Card className={cn("h-full overflow-hidden px-0 pb-2", tabbed ? "pt-0" : "pt-3", className)}>
|
||||
{tabbed ? (
|
||||
<div className={cn(TITLE_BAR_CHROME, "items-stretch justify-between gap-x-0 pl-4 pr-2")}>
|
||||
<div className="flex items-stretch gap-x-4">{title}</div>
|
||||
{maximizable && <div className="flex items-center">{maximizeButton}</div>}
|
||||
</div>
|
||||
) : (
|
||||
<Card.Header>
|
||||
<div className="flex items-center gap-1.5">{title}</div>
|
||||
{maximizable && <Card.Accessory>{maximizeButton}</Card.Accessory>}
|
||||
</Card.Header>
|
||||
)}
|
||||
<div className={cn("min-h-0 flex-1 px-2", tabbed && "pt-3")}>{children}</div>
|
||||
</Card>
|
||||
|
||||
{maximizable && (
|
||||
@@ -90,7 +107,9 @@ export function ChartCard({
|
||||
<DialogContent fullscreen className="flex flex-col bg-background-bright">
|
||||
{/* In fullscreen, space the title's legend (the flex-col title node) further from the
|
||||
title — gap-6 instead of the card's gap-1. */}
|
||||
<DialogHeader className="[&>span]:gap-6">{title}</DialogHeader>
|
||||
<DialogHeader className="[&>span]:gap-6">
|
||||
{tabbed ? <div className="flex items-stretch gap-x-4">{title}</div> : title}
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 w-full flex-1 overflow-hidden pt-4">
|
||||
{parentSync ? (
|
||||
<ChartSyncProvider onZoom={parentSync.onZoom}>
|
||||
|
||||
@@ -312,7 +312,10 @@ function ViewAllDataRow({ remainingCount, onViewAll }: ViewAllDataRowProps) {
|
||||
<div className="w-1 shrink-0 self-stretch rounded-[2px] border border-border-bright" />
|
||||
<span className="text-text-dimmed tabular-nums">{remainingCount} more…</span>
|
||||
</div>
|
||||
<span className="self-start text-indigo-500">View all</span>
|
||||
{/* The row is the button, so this only takes the link color - it gets its
|
||||
hover from the parent, and it isn't body text the underline
|
||||
preference should reach. */}
|
||||
<span className="self-start text-text-link">View all</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -168,7 +168,7 @@ function ReferenceLineLabel({
|
||||
y={viewBox.y}
|
||||
dominantBaseline="middle"
|
||||
textAnchor="start"
|
||||
fill="#878C99"
|
||||
className="fill-text-dimmed"
|
||||
fontSize={REFERENCE_LABEL_FONT_SIZE}
|
||||
>
|
||||
{value}
|
||||
@@ -180,7 +180,7 @@ function ReferenceLineLabel({
|
||||
x={viewBox.x + viewBox.width - 4}
|
||||
y={viewBox.y + 12}
|
||||
textAnchor="end"
|
||||
fill="#878C99"
|
||||
className="fill-text-dimmed"
|
||||
fontSize={REFERENCE_LABEL_FONT_SIZE}
|
||||
>
|
||||
{value}
|
||||
@@ -458,7 +458,7 @@ export function ChartLineRenderer({
|
||||
<ReferenceLine
|
||||
key={`ref-${line.y}-${line.label ?? ""}`}
|
||||
y={line.y}
|
||||
stroke={line.color ?? "#4D525B"}
|
||||
stroke={line.color ?? "var(--color-border-brighter)"}
|
||||
strokeDasharray="4 4"
|
||||
strokeWidth={1}
|
||||
// extendDomain (not "hidden") means recharts does NOT wrap this layer in the plot
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UIMessage } from "@ai-sdk/react";
|
||||
import { memo } from "react";
|
||||
import { AssistantResponse, ChatBubble, ToolUseRow } from "~/components/runs/v3/ai/AIChatMessages";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import { textLinkClassName } from "~/components/primitives/TextLink";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentMessageView — renders an AI SDK UIMessage[] conversation.
|
||||
@@ -219,12 +220,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
|
||||
}
|
||||
return (
|
||||
<div key={i} className="text-xs">
|
||||
<a
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-400 underline hover:text-indigo-300"
|
||||
>
|
||||
<a href={safeUrl} target="_blank" rel="noopener noreferrer" className={textLinkClassName()}>
|
||||
{label}
|
||||
</a>
|
||||
</div>
|
||||
@@ -274,12 +270,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) {
|
||||
}
|
||||
return (
|
||||
<div key={i} className="text-xs">
|
||||
<a
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-400 underline hover:text-indigo-300"
|
||||
>
|
||||
<a href={safeUrl} target="_blank" rel="noopener noreferrer" className={textLinkClassName()}>
|
||||
{p.filename ?? "Download file"}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState } from "react";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import type { AISpanData, ToolDefinition } from "./types";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { textLinkClassName } from "~/components/primitives/TextLink";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function AIToolsInventory({ aiData }: { aiData: AISpanData }) {
|
||||
const defs = aiData.toolDefinitions ?? [];
|
||||
@@ -49,7 +51,7 @@ function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolea
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSchema(!showSchema)}
|
||||
className="text-[10px] text-text-link hover:underline"
|
||||
className={cn(textLinkClassName(), "text-[10px]")}
|
||||
>
|
||||
{showSchema ? "Hide schema" : "Show schema"}
|
||||
</button>
|
||||
|
||||
@@ -245,7 +245,7 @@ export function PurchaseSchedulesModal({
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Send request for ${formatNumber(
|
||||
<span className="tabular-nums">{`Send request for ${formatNumber(
|
||||
amountValue
|
||||
)}`}</span>
|
||||
</Button>
|
||||
@@ -259,7 +259,7 @@ export function PurchaseSchedulesModal({
|
||||
disabled={isLoading || state === "need_to_delete"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Remove ${formatNumber(
|
||||
<span className="tabular-nums">{`Remove ${formatNumber(
|
||||
extraSchedules - amountValue
|
||||
)} ${extraSchedules - amountValue === 1 ? "schedule" : "schedules"}`}</span>
|
||||
</Button>
|
||||
@@ -273,7 +273,7 @@ export function PurchaseSchedulesModal({
|
||||
disabled={isLoading || state === "no_change"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Purchase ${formatNumber(
|
||||
<span className="tabular-nums">{`Purchase ${formatNumber(
|
||||
amountValue - extraSchedules
|
||||
)} ${amountValue - extraSchedules === 1 ? "schedule" : "schedules"}`}</span>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type FunctionComponent } from "react";
|
||||
import { CircleFilledIcon } from "~/assets/icons/CircleFilledIcon";
|
||||
import { CircleOutlineIcon } from "~/assets/icons/CircleOutlineIcon";
|
||||
import { MonitorIcon } from "~/assets/icons/MonitorIcon";
|
||||
import { MoonIcon } from "~/assets/icons/MoonIcon";
|
||||
import { SunIcon } from "~/assets/icons/SunIcon";
|
||||
import { type ThemeAppearance } from "~/hooks/useSystemThemeSync";
|
||||
import { type ThemePreference } from "~/utils/themePreference";
|
||||
|
||||
export type ThemeOption = {
|
||||
value: ThemePreference;
|
||||
label: string;
|
||||
icon: FunctionComponent<{ className?: string }>;
|
||||
};
|
||||
|
||||
/** Shared by every theme picker, in display order. */
|
||||
export const THEME_OPTIONS: ThemeOption[] = [
|
||||
{ value: "system", label: "System", icon: MonitorIcon },
|
||||
{ value: "light", label: "Light", icon: SunIcon },
|
||||
{ value: "dark", label: "Dark", icon: MoonIcon },
|
||||
];
|
||||
|
||||
/** Account page only. Icons are the dark-theme pair; `themeOptionIcon` swaps them. */
|
||||
const FLAT_OPTIONS: ThemeOption[] = [
|
||||
{ value: "white", label: "White", icon: CircleFilledIcon },
|
||||
{ value: "black", label: "Black", icon: CircleOutlineIcon },
|
||||
];
|
||||
|
||||
export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, ...FLAT_OPTIONS];
|
||||
|
||||
export const THEME_OPTIONS_BY_VALUE = Object.fromEntries(
|
||||
ALL_THEME_OPTIONS.map((option) => [option.value, option])
|
||||
) as Record<ThemePreference, ThemeOption>;
|
||||
|
||||
/**
|
||||
* Black and White show the active background through the circle: the option
|
||||
* matching the current end is a ring, the opposing one a solid disc.
|
||||
*/
|
||||
export function themeOptionIcon(option: ThemeOption, appearance: ThemeAppearance) {
|
||||
if (option.value === "black") {
|
||||
return appearance === "dark" ? CircleOutlineIcon : CircleFilledIcon;
|
||||
}
|
||||
if (option.value === "white") {
|
||||
return appearance === "light" ? CircleOutlineIcon : CircleFilledIcon;
|
||||
}
|
||||
return option.icon;
|
||||
}
|
||||
|
||||
export const SYSTEM_LIGHT_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter(
|
||||
(option) => option.value === "light" || option.value === "white"
|
||||
);
|
||||
export const SYSTEM_DARK_OPTIONS: ThemeOption[] = ALL_THEME_OPTIONS.filter(
|
||||
(option) => option.value === "dark" || option.value === "black"
|
||||
);
|
||||
@@ -1,29 +1,96 @@
|
||||
import { useEffect } from "react";
|
||||
import { type ThemePreference } from "~/utils/themePreference";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type SystemDarkTheme,
|
||||
type SystemLightTheme,
|
||||
type ThemePreference,
|
||||
} from "~/utils/themePreference";
|
||||
|
||||
/** Which theme `system` lands on at each end of the OS setting. */
|
||||
export type SystemThemes = { light: SystemLightTheme; dark: SystemDarkTheme };
|
||||
|
||||
const DEFAULT_SYSTEM_THEMES: SystemThemes = { light: "light", dark: "dark" };
|
||||
|
||||
/** Which end of the scale a theme sits on. */
|
||||
export type ThemeAppearance = "dark" | "light";
|
||||
|
||||
function themeAppearance(preference: ThemePreference, prefersDark: boolean): ThemeAppearance {
|
||||
if (preference === "system") return prefersDark ? "dark" : "light";
|
||||
return preference === "light" || preference === "white" ? "light" : "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps `data-theme` on <html> in sync with the preference. For `system` it
|
||||
* follows the OS color scheme live; for pinned themes it writes the attribute
|
||||
* explicitly - React can skip the write when its virtual DOM already matched
|
||||
* the SSR fallback while the inline script had changed the real attribute.
|
||||
* The single resolution rule (dark vs light) lives here and in the blocking
|
||||
* inline script in root.tsx; downstream consumers react to the `data-theme`
|
||||
* mutation (see useThemeColor).
|
||||
* Resolved appearance, tracking the OS while the preference is `system`. Defaults
|
||||
* to dark before the effect runs, matching root.tsx's SSR fallback.
|
||||
*/
|
||||
export function useSystemThemeSync(preference: ThemePreference) {
|
||||
export function useThemeAppearance(preference: ThemePreference): ThemeAppearance {
|
||||
const [prefersDark, setPrefersDark] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (preference !== "system") return;
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const apply = () => setPrefersDark(media.matches);
|
||||
apply();
|
||||
media.addEventListener("change", apply);
|
||||
return () => media.removeEventListener("change", apply);
|
||||
}, [preference]);
|
||||
|
||||
return themeAppearance(preference, prefersDark);
|
||||
}
|
||||
|
||||
/** Only `system` needs resolving; it lands on the variant picked for that end. */
|
||||
export function resolveThemePreference(
|
||||
preference: ThemePreference,
|
||||
prefersDark: boolean,
|
||||
systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES
|
||||
): ThemePreference {
|
||||
if (preference !== "system") return preference;
|
||||
return prefersDark ? systemThemes.dark : systemThemes.light;
|
||||
}
|
||||
|
||||
/** Just the percent; each theme maps it onto its own range in CSS. */
|
||||
export function applyThemeContrast(percent: number) {
|
||||
document.documentElement.style.setProperty("--theme-contrast-percent", String(percent / 100));
|
||||
}
|
||||
|
||||
/** Applies a theme immediately, rather than waiting for the loader round-trip. */
|
||||
export function applyThemePreference(
|
||||
preference: ThemePreference,
|
||||
systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES
|
||||
) {
|
||||
const prefersDark =
|
||||
preference === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.setAttribute(
|
||||
"data-theme",
|
||||
resolveThemePreference(preference, prefersDark, systemThemes)
|
||||
);
|
||||
document.documentElement.setAttribute("data-theme-preference", preference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps `data-theme` in sync with the preference. Pinned themes are written
|
||||
* explicitly: React can skip a write its virtual DOM thinks already matched,
|
||||
* while root.tsx's inline script had changed the real attribute.
|
||||
*/
|
||||
export function useSystemThemeSync(
|
||||
preference: ThemePreference,
|
||||
systemThemes: SystemThemes = DEFAULT_SYSTEM_THEMES
|
||||
) {
|
||||
const { light, dark } = systemThemes;
|
||||
|
||||
useEffect(() => {
|
||||
if (preference !== "system") {
|
||||
document.documentElement.setAttribute("data-theme", preference);
|
||||
applyThemePreference(preference);
|
||||
return;
|
||||
}
|
||||
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const apply = () => {
|
||||
document.documentElement.setAttribute("data-theme", media.matches ? "dark" : "light");
|
||||
document.documentElement.setAttribute("data-theme", media.matches ? dark : light);
|
||||
};
|
||||
|
||||
apply();
|
||||
media.addEventListener("change", apply);
|
||||
return () => media.removeEventListener("change", apply);
|
||||
}, [preference]);
|
||||
// Destructured so a fresh object each render doesn't re-run this
|
||||
}, [preference, light, dark]);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { SystemLightTheme } from "~/utils/themePreference";
|
||||
|
||||
export type ThemeMode = "dark" | "light";
|
||||
|
||||
/* Which themes read as light. Taken from the enum that also drives the "Light"
|
||||
end of the `system` preference, so a new theme only has to be classified once
|
||||
- anything not in here (dark, black) reads as dark. */
|
||||
const LIGHT_THEMES = new Set<string>(SystemLightTheme.options);
|
||||
|
||||
/**
|
||||
* The active theme's mode, for colors that can't come from a CSS variable. Resolved in an
|
||||
* effect so server and hydration renders agree; `root.tsx` can flip `data-theme` pre-paint.
|
||||
@@ -10,7 +16,8 @@ export function useThemeMode(): ThemeMode {
|
||||
const [mode, setMode] = useState<ThemeMode>("dark");
|
||||
useEffect(() => {
|
||||
const resolve = () => {
|
||||
setMode(document.documentElement.getAttribute("data-theme") === "light" ? "light" : "dark");
|
||||
const theme = document.documentElement.getAttribute("data-theme");
|
||||
setMode(theme !== null && LIGHT_THEMES.has(theme) ? "light" : "dark");
|
||||
};
|
||||
resolve();
|
||||
const observer = new MutationObserver(resolve);
|
||||
|
||||
@@ -397,3 +397,37 @@ export function updateUser({
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One column each. `updateUser` above is the onboarding write and confirms basic
|
||||
* details as a side effect, which is wrong for a profile edit.
|
||||
*/
|
||||
export function updateUserName({ id, name }: Pick<User, "id" | "name">) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
export function updateUserEmail({ id, email }: Pick<User, "id" | "email">) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data: { email },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `updateMany` so the WHERE does the comparing: a redundant request updates zero
|
||||
* rows rather than churning the row and its updatedAt.
|
||||
*/
|
||||
export async function updateUserMarketingEmails({
|
||||
id,
|
||||
marketingEmails,
|
||||
}: Pick<User, "id" | "marketingEmails">) {
|
||||
const { count } = await prisma.user.updateMany({
|
||||
where: { id, marketingEmails: { not: marketingEmails } },
|
||||
data: { marketingEmails },
|
||||
});
|
||||
|
||||
return { changed: count > 0 };
|
||||
}
|
||||
|
||||
@@ -7,9 +7,7 @@ import { newOrganizationPath, newProjectPath } from "~/utils/pathBuilder";
|
||||
import { SelectBestEnvironmentPresenter } from "./SelectBestEnvironmentPresenter.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
|
||||
import { env } from "~/env.server";
|
||||
import { flags } from "~/v3/featureFlags.server";
|
||||
import { validatePartialFeatureFlags } from "~/v3/featureFlags";
|
||||
import { globalFeatureFlags, mergeOrgFeatureFlags } from "~/v3/featureFlags.server";
|
||||
import { hydrateEnvsWithActivity } from "./v3/BranchesPresenter.server";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
@@ -155,23 +153,10 @@ export class OrganizationsPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
// Get global feature flags with env-var-based defaults
|
||||
const globalFlags = await flags({
|
||||
defaultValues: {
|
||||
hasAiAccess: env.AI_FEATURES_ENABLED === "1",
|
||||
hasDashboardAgentAccess: env.DASHBOARD_AGENT_ENABLED === "1",
|
||||
hasPrivateConnections: env.PRIVATE_CONNECTIONS_ENABLED === "1",
|
||||
},
|
||||
});
|
||||
const globalFlags = await globalFeatureFlags();
|
||||
|
||||
return orgs.map((org) => {
|
||||
const orgFlagsResult = org.featureFlags
|
||||
? validatePartialFeatureFlags(org.featureFlags as Record<string, unknown>)
|
||||
: ({ success: false } as const);
|
||||
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};
|
||||
|
||||
// Combine global flags with org flags (org flags win)
|
||||
const combinedFlags = { ...globalFlags, ...orgFlags };
|
||||
const combinedFlags = mergeOrgFeatureFlags(globalFlags, org.featureFlags);
|
||||
|
||||
return {
|
||||
id: org.id,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { type ClickHouse } from "@internal/clickhouse";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { backstopPromise } from "~/utils/backstopPromise";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
|
||||
@@ -85,15 +86,18 @@ class AgentListPresenter {
|
||||
};
|
||||
}
|
||||
|
||||
// All queries are deferred for streaming
|
||||
const activeStates = this.#getActiveStates(clickhouse, environmentId, slugs);
|
||||
const conversationSparklines = this.#getConversationSparklines(
|
||||
clickhouse,
|
||||
environmentId,
|
||||
slugs
|
||||
// Deferred for streaming, and backstopped: consumers subscribe late or,
|
||||
// for some callers, not at all.
|
||||
const activeStates = backstopPromise(this.#getActiveStates(clickhouse, environmentId, slugs));
|
||||
const conversationSparklines = backstopPromise(
|
||||
this.#getConversationSparklines(clickhouse, environmentId, slugs)
|
||||
);
|
||||
const costSparklines = backstopPromise(
|
||||
this.#getCostSparklines(clickhouse, environmentId, slugs)
|
||||
);
|
||||
const tokenSparklines = backstopPromise(
|
||||
this.#getTokenSparklines(clickhouse, environmentId, slugs)
|
||||
);
|
||||
const costSparklines = this.#getCostSparklines(clickhouse, environmentId, slugs);
|
||||
const tokenSparklines = this.#getTokenSparklines(clickhouse, environmentId, slugs);
|
||||
|
||||
return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ClickHouseEnvironmentMetricsRepository,
|
||||
type CurrentRunningStats,
|
||||
} from "~/services/environmentMetricsRepository.server";
|
||||
import { backstopPromise } from "~/utils/backstopPromise";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
|
||||
@@ -84,16 +85,17 @@ class TaskListPresenter {
|
||||
});
|
||||
|
||||
// IMPORTANT: Don't await this, we want to return the promise
|
||||
// so we can defer the loading of the data. The caller is responsible for
|
||||
// consuming it — an unconsumed promise here would become an unhandled
|
||||
// rejection if the underlying query fails.
|
||||
const runningStats = environmentMetricsRepository.getCurrentRunningStats({
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
days: 6,
|
||||
tasks: slugs,
|
||||
});
|
||||
// so we can defer the loading of the data. Backstopped: the caller
|
||||
// subscribes only after further awaits.
|
||||
const runningStats = backstopPromise(
|
||||
environmentMetricsRepository.getCurrentRunningStats({
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
days: 6,
|
||||
tasks: slugs,
|
||||
})
|
||||
);
|
||||
|
||||
return { tasks, runningStats };
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
|
||||
import { backstopPromise } from "~/utils/backstopPromise";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
import { agentListPresenter, type AgentActiveState } from "./AgentListPresenter.server";
|
||||
@@ -69,26 +70,31 @@ class UnifiedTaskListPresenter {
|
||||
const items = toUnifiedItems(taskResult.tasks, agentResult.agents);
|
||||
const allSlugs = items.map((item) => item.slug);
|
||||
|
||||
// Backstopped: the route subscribes via typeddefer only after further
|
||||
// awaits, so a rejection in that gap would be unhandled.
|
||||
const hourlyActivity: Promise<HourlyTaskActivity> =
|
||||
allSlugs.length === 0
|
||||
? Promise.resolve({})
|
||||
: (async () => {
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
args.organizationId,
|
||||
"standard"
|
||||
);
|
||||
return getHourlyTaskActivity(clickhouse, {
|
||||
organizationId: args.organizationId,
|
||||
projectId: args.projectId,
|
||||
environmentId: args.environmentId,
|
||||
slugs: allSlugs,
|
||||
});
|
||||
})();
|
||||
: backstopPromise(
|
||||
(async () => {
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
args.organizationId,
|
||||
"standard"
|
||||
);
|
||||
return getHourlyTaskActivity(clickhouse, {
|
||||
organizationId: args.organizationId,
|
||||
projectId: args.projectId,
|
||||
environmentId: args.environmentId,
|
||||
slugs: allSlugs,
|
||||
});
|
||||
})()
|
||||
);
|
||||
|
||||
const runningStates: Promise<UnifiedRunningStates> = Promise.all([
|
||||
taskResult.runningStats,
|
||||
agentResult.activeStates,
|
||||
]).then(([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates));
|
||||
const runningStates: Promise<UnifiedRunningStates> = backstopPromise(
|
||||
Promise.all([taskResult.runningStats, agentResult.activeStates]).then(
|
||||
([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates)
|
||||
)
|
||||
);
|
||||
|
||||
return { items, hourlyActivity, runningStates };
|
||||
}
|
||||
|
||||
+47
-18
@@ -20,11 +20,15 @@ import { TimezoneSetter } from "./components/TimezoneSetter";
|
||||
import { env } from "./env.server";
|
||||
import { featuresForRequest } from "./features.server";
|
||||
import { usePostHog } from "./hooks/usePostHog";
|
||||
import { useSystemThemeSync } from "./hooks/useSystemThemeSync";
|
||||
import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync";
|
||||
import { getImpersonationState } from "./services/impersonation.server";
|
||||
import { getUser } from "./services/session.server";
|
||||
import {
|
||||
normalizeIconContrast,
|
||||
normalizeSystemDarkTheme,
|
||||
normalizeSystemLightTheme,
|
||||
normalizeThemeContrast,
|
||||
normalizeUnderlineLinks,
|
||||
normalizeThemePreference,
|
||||
type ThemePreference,
|
||||
} from "~/utils/themePreference";
|
||||
@@ -81,20 +85,29 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
};
|
||||
|
||||
const user = await getUser(request);
|
||||
// Theme switching is feature-flagged; while off, everyone stays on the
|
||||
// classic theme even if a preference was saved earlier. Admins always get
|
||||
// the switcher so the team can dogfood before the flag flips. Cached: the
|
||||
// root loader runs on every document request and client navigation.
|
||||
// Feature-flagged; while off everyone stays on Dark at contrast 0. Admins
|
||||
// always get it. Cached: this loader runs on every request and navigation.
|
||||
const showThemeSwitcher = user
|
||||
? user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false }))
|
||||
: false;
|
||||
// Logged-out pages (login, invites) always render the branded Classic look.
|
||||
// Logged-out pages always render the branded dark look.
|
||||
const themePreference: ThemePreference = showThemeSwitcher
|
||||
? normalizeThemePreference(user?.dashboardPreferences.theme)
|
||||
: "classic";
|
||||
: "dark";
|
||||
const themeContrast = showThemeSwitcher
|
||||
? normalizeThemeContrast(user?.dashboardPreferences.contrast)
|
||||
: 0;
|
||||
// Forced off with the switcher hidden, so unflagged pages render the base set.
|
||||
const iconContrast = showThemeSwitcher
|
||||
? normalizeIconContrast(user?.dashboardPreferences.iconContrast)
|
||||
: false;
|
||||
const underlineLinks = showThemeSwitcher
|
||||
? normalizeUnderlineLinks(user?.dashboardPreferences.underlineLinks)
|
||||
: false;
|
||||
const systemThemes = {
|
||||
light: normalizeSystemLightTheme(user?.dashboardPreferences.systemLightTheme),
|
||||
dark: normalizeSystemDarkTheme(user?.dashboardPreferences.systemDarkTheme),
|
||||
};
|
||||
// Display-only: while impersonating, an admin can ask to see the dashboard
|
||||
// the way the impersonated user sees it. Exposed from root so every route can
|
||||
// read it.
|
||||
@@ -125,7 +138,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
kapa,
|
||||
timezone,
|
||||
showThemeSwitcher,
|
||||
iconContrast,
|
||||
underlineLinks,
|
||||
themePreference,
|
||||
systemThemes,
|
||||
themeContrast,
|
||||
// Consumed by ResizablePanel: the browser check must match between SSR
|
||||
// and hydration, so it is derived from the request user-agent.
|
||||
@@ -147,7 +163,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = (options) => {
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<html lang="en" className="h-full" data-theme="classic">
|
||||
<html lang="en" className="h-full" data-theme="dark">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
|
||||
@@ -170,29 +186,42 @@ export function ErrorBoundary() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { posthogProjectKey, posthogUiHost, themePreference, themeContrast } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const {
|
||||
posthogProjectKey,
|
||||
posthogUiHost,
|
||||
themePreference,
|
||||
themeContrast,
|
||||
iconContrast,
|
||||
underlineLinks,
|
||||
systemThemes,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
usePostHog(posthogProjectKey, posthogUiHost);
|
||||
useSystemThemeSync(themePreference);
|
||||
// SSR falls back to dark for `system`; the inline script below corrects it
|
||||
// before paint, and useSystemThemeSync keeps it live afterwards.
|
||||
const resolvedTheme = themePreference === "system" ? "dark" : themePreference;
|
||||
useSystemThemeSync(themePreference, systemThemes);
|
||||
// SSR falls back to the dark end for `system`; the script below fixes it
|
||||
// before paint, and useSystemThemeSync keeps it live after.
|
||||
const resolvedTheme = resolveThemePreference(themePreference, true, systemThemes);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className="h-full"
|
||||
// The pre-paint script below may flip data-theme before hydration
|
||||
// The script below may flip data-theme before hydration
|
||||
suppressHydrationWarning
|
||||
data-theme={resolvedTheme}
|
||||
data-theme-preference={themePreference}
|
||||
// Contrast overlay input for the System themes; Classic never reads it
|
||||
style={{ "--theme-contrast": themeContrast / 100 } as CSSProperties}
|
||||
// Read by the script below, before loader data reaches JS
|
||||
data-system-light={systemThemes.light}
|
||||
data-system-dark={systemThemes.dark}
|
||||
// The `system:` variant keys off this
|
||||
data-icon-contrast={iconContrast ? "true" : "false"}
|
||||
data-underline-links={underlineLinks ? "true" : "false"}
|
||||
// Each theme maps the percent onto its own range in CSS
|
||||
style={{ "--theme-contrast-percent": themeContrast / 100 } as CSSProperties}
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `try{if(document.documentElement.getAttribute("data-theme-preference")==="system"){document.documentElement.setAttribute("data-theme",matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")}}catch(e){}`,
|
||||
__html: `try{var h=document.documentElement;if(h.getAttribute("data-theme-preference")==="system"){var d=matchMedia("(prefers-color-scheme: dark)").matches;h.setAttribute("data-theme",d?(h.getAttribute("data-system-dark")||"dark"):(h.getAttribute("data-system-light")||"light"))}}catch(e){}`,
|
||||
}}
|
||||
/>
|
||||
<StaleAssetRecovery isProduction={isProduction} />
|
||||
|
||||
+4
-1
@@ -1114,7 +1114,10 @@ function ApiKeyScopePanel({
|
||||
? "bg-blue-500"
|
||||
: tone === "write"
|
||||
? "bg-amber-500"
|
||||
: "bg-charcoal-600"
|
||||
: // A dot has to stay visible, so this tracks the
|
||||
// dimmed text color rather than a surface color
|
||||
// that would all but vanish on a light card.
|
||||
"bg-text-dimmed"
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
|
||||
+3
-3
@@ -816,7 +816,7 @@ function PurchaseBranchesModal({
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Send request for ${formatNumber(
|
||||
<span className="tabular-nums">{`Send request for ${formatNumber(
|
||||
amountValue
|
||||
)}`}</span>
|
||||
</Button>
|
||||
@@ -830,7 +830,7 @@ function PurchaseBranchesModal({
|
||||
disabled={isLoading || state === "need_to_archive"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Remove ${formatNumber(
|
||||
<span className="tabular-nums">{`Remove ${formatNumber(
|
||||
extraBranches - amountValue
|
||||
)} ${extraBranches - amountValue === 1 ? "branch" : "branches"}`}</span>
|
||||
</Button>
|
||||
@@ -844,7 +844,7 @@ function PurchaseBranchesModal({
|
||||
disabled={isLoading || state === "no_change"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Purchase ${formatNumber(
|
||||
<span className="tabular-nums">{`Purchase ${formatNumber(
|
||||
amountValue - extraBranches
|
||||
)} ${amountValue - extraBranches === 1 ? "branch" : "branches"}`}</span>
|
||||
</Button>
|
||||
|
||||
+2
-1
@@ -61,6 +61,7 @@ import {
|
||||
getPlans,
|
||||
getSelfServePurchaseBlockReason,
|
||||
} from "~/services/platform.v3.server";
|
||||
import { textLinkClassName } from "~/components/primitives/TextLink";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrency, formatNumber } from "~/utils/numberFormatter";
|
||||
@@ -437,7 +438,7 @@ function Upgradable({
|
||||
Save your changes or{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="inline text-indigo-500 hover:text-indigo-300"
|
||||
className={cn(textLinkClassName(), "inline")}
|
||||
onClick={() => {
|
||||
setAllocation(initialAllocation(environments));
|
||||
}}
|
||||
|
||||
+5
-4
@@ -1,4 +1,4 @@
|
||||
import { Link, useLocation } from "@remix-run/react";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
@@ -59,6 +59,7 @@ export const handle: Handle = {
|
||||
agentPageContext: (data) => deploymentAgentPageContext(data),
|
||||
};
|
||||
import { pageMeta } from "~/utils/pageTitle";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
|
||||
export const meta = pageMeta(({ params }) => [
|
||||
params.deploymentParam ?? "Deployment",
|
||||
@@ -350,12 +351,12 @@ export default function Page() {
|
||||
<Property.Item>
|
||||
<Property.Label>Build Server</Property.Label>
|
||||
<Property.Value>
|
||||
<Link
|
||||
<TextLink
|
||||
to={`/resources/${deployment.projectId}/deployments/${deployment.id}/logs`}
|
||||
className="extra-small/bright/mono underline"
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{deployment.externalBuildData.buildId}
|
||||
</Link>
|
||||
</TextLink>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
+9
-4
@@ -89,6 +89,7 @@ export const handle: Handle = {
|
||||
agentPageContext: () => deploymentsAgentPageContext(),
|
||||
};
|
||||
import { pageMeta } from "~/utils/pageTitle";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
|
||||
export const meta = pageMeta("Deployments");
|
||||
|
||||
@@ -400,14 +401,18 @@ export default function Page() {
|
||||
<span className="max-w-28 truncate">{environmentGitHubBranch}</span>
|
||||
</div>{" "}
|
||||
in
|
||||
<a
|
||||
<TextLink
|
||||
href={connectedGithubRepository.repository.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="max-w-52 truncate text-sm text-text-dimmed underline transition-colors hover:text-text-bright"
|
||||
variant="secondary"
|
||||
className="text-sm"
|
||||
>
|
||||
{connectedGithubRepository.repository.fullName}
|
||||
</a>
|
||||
{/* truncate needs a block-level child: the link itself is inline-flex */}
|
||||
<span className="max-w-52 truncate">
|
||||
{connectedGithubRepository.repository.fullName}
|
||||
</span>
|
||||
</TextLink>
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={CogIcon}
|
||||
|
||||
+11
-5
@@ -3,8 +3,13 @@ import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { textLinkClassName } from "~/components/primitives/TextLink";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import type { QueryScope } from "~/services/queryService.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
/** Table-of-contents links: the shared text-link treatment, laid out as rows. */
|
||||
const TOC_LINK = cn(textLinkClassName(), "block");
|
||||
|
||||
/** A code block with an integrated "Try it" button */
|
||||
export function TryableCodeBlock({
|
||||
@@ -80,20 +85,21 @@ export function TRQLGuideContent({
|
||||
it with additional features.
|
||||
</Paragraph>
|
||||
{/* Table of contents */}
|
||||
{/* `block` rather than TextLink's inline-flex, so each entry is its own row. */}
|
||||
<nav className="space-y-1 text-sm">
|
||||
<a href="#basic" className="block text-text-link hover:underline">
|
||||
<a href="#basic" className={TOC_LINK}>
|
||||
Basic queries
|
||||
</a>
|
||||
<a href="#filtering" className="block text-text-link hover:underline">
|
||||
<a href="#filtering" className={TOC_LINK}>
|
||||
Filtering with WHERE
|
||||
</a>
|
||||
<a href="#sorting" className="block text-text-link hover:underline">
|
||||
<a href="#sorting" className={TOC_LINK}>
|
||||
Sorting & limiting
|
||||
</a>
|
||||
<a href="#grouping" className="block text-text-link hover:underline">
|
||||
<a href="#grouping" className={TOC_LINK}>
|
||||
Grouping & aggregation
|
||||
</a>
|
||||
<a href="#functions" className="block text-text-link hover:underline">
|
||||
<a href="#functions" className={TOC_LINK}>
|
||||
Available functions
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
+6
-6
@@ -1578,11 +1578,11 @@ function queueHealthLabel({ paused, running, queued, limit }: QueueHealth): Queu
|
||||
|
||||
// Tint + colored text, sized like the error status chips (see ErrorStatusBadge).
|
||||
const QUEUE_HEALTH_STYLES: Record<QueueHealthLabel, string> = {
|
||||
Paused: "bg-warning/10 text-warning",
|
||||
"At capacity": "bg-warning/10 text-warning",
|
||||
Backlogged: "bg-blue-500/10 text-blue-500",
|
||||
Active: "bg-success/10 text-success",
|
||||
Idle: "bg-charcoal-500/10 text-text-dimmed",
|
||||
Paused: "bg-warning/10 text-warning system:bg-warning system:text-white",
|
||||
"At capacity": "bg-warning/10 text-warning system:bg-warning system:text-white",
|
||||
Backlogged: "bg-blue-500/10 text-blue-500 system:bg-blue-500 system:text-white",
|
||||
Active: "bg-success/10 text-success system:bg-success system:text-white",
|
||||
Idle: "bg-charcoal-500/10 text-text-dimmed system:bg-charcoal-500 system:text-white",
|
||||
};
|
||||
|
||||
function QueueHealthBadge(health: QueueHealth) {
|
||||
@@ -1590,7 +1590,7 @@ function QueueHealthBadge(health: QueueHealth) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"contrast-chip ml-auto inline-flex w-fit items-center rounded px-2 py-0.5 text-xs font-medium",
|
||||
"ml-auto inline-flex w-fit items-center rounded px-2 py-0.5 text-xs font-medium",
|
||||
QUEUE_HEALTH_STYLES[label]
|
||||
)}
|
||||
>
|
||||
|
||||
+3
-2
@@ -45,7 +45,8 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { TextLink, textLinkClassName } from "~/components/primitives/TextLink";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
@@ -358,7 +359,7 @@ export default function Page() {
|
||||
<TextLink to="https://security.trigger.dev">security portal</TextLink> or{" "}
|
||||
<Feedback
|
||||
button={
|
||||
<span className="cursor-pointer text-xs text-indigo-500 transition hover:text-indigo-400">
|
||||
<span className={cn(textLinkClassName(), "cursor-pointer text-xs")}>
|
||||
get in touch
|
||||
</span>
|
||||
}
|
||||
|
||||
+8
-3
@@ -10,7 +10,7 @@ import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { DirectionSchema, ListPagination } from "~/components/ListPagination";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ChartCard } from "~/components/primitives/charts/ChartCard";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TabButton } from "~/components/primitives/Tabs";
|
||||
import { ChartSyncProvider } from "~/components/primitives/charts/ChartSyncContext";
|
||||
import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter";
|
||||
import { Chart, type ChartConfig } from "~/components/primitives/charts/ChartCompound";
|
||||
@@ -491,11 +491,14 @@ function TaskActivityCard({
|
||||
const [view, setView] = useState<"runs" | "queue">("runs");
|
||||
return (
|
||||
<ChartCard
|
||||
headerVariant="tabs"
|
||||
title={
|
||||
<TabContainer>
|
||||
<>
|
||||
<TabButton
|
||||
isActive={view === "runs"}
|
||||
layoutId="task-activity-view"
|
||||
variant="title"
|
||||
size="small"
|
||||
onClick={() => setView("runs")}
|
||||
>
|
||||
Runs by status
|
||||
@@ -503,11 +506,13 @@ function TaskActivityCard({
|
||||
<TabButton
|
||||
isActive={view === "queue"}
|
||||
layoutId="task-activity-view"
|
||||
variant="title"
|
||||
size="small"
|
||||
onClick={() => setView("queue")}
|
||||
>
|
||||
Queue backlog
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{view === "queue" ? (
|
||||
|
||||
+4
-10
@@ -46,6 +46,8 @@ import {
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { WhenAgentUnavailable } from "~/components/dashboard-agent/WhenAgentUnavailable";
|
||||
import { textLinkClassName } from "~/components/primitives/TextLink";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { pageMeta } from "~/utils/pageTitle";
|
||||
|
||||
export const meta = pageMeta("Add Private Connection");
|
||||
@@ -325,11 +327,7 @@ output "endpoint_service_name" {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPort}
|
||||
className="text-xs text-indigo-400 transition hover:text-indigo-300"
|
||||
>
|
||||
<button type="button" onClick={addPort} className={cn(textLinkClassName(), "text-xs")}>
|
||||
+ Add port
|
||||
</button>
|
||||
</div>
|
||||
@@ -471,11 +469,7 @@ After creating everything, give me the VPC Endpoint Service name (it looks like
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPort}
|
||||
className="text-xs text-indigo-400 transition hover:text-indigo-300"
|
||||
>
|
||||
<button type="button" onClick={addPort} className={cn(textLinkClassName(), "text-xs")}>
|
||||
+ Add port
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
import { SetSeatsAddOnService } from "~/v3/services/setSeatsAddOn.server";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { pageMeta } from "~/utils/pageTitle";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
|
||||
export const meta = pageMeta("Team");
|
||||
|
||||
@@ -465,12 +466,9 @@ export default function Page() {
|
||||
<div className="mt-4 flex items-baseline justify-between">
|
||||
<Header2>Active team members</Header2>
|
||||
{roles.length > 0 ? (
|
||||
<a
|
||||
className="text-xs text-text-link hover:underline"
|
||||
href={organizationRolesPath(organization)}
|
||||
>
|
||||
View all role permissions →
|
||||
</a>
|
||||
<TextLink to={organizationRolesPath(organization)} className="text-xs">
|
||||
View all role permissions
|
||||
</TextLink>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-8 mt-3 grid w-full grid-cols-[1fr_auto_auto] items-center gap-x-2 border-y border-grid-bright">
|
||||
@@ -1154,7 +1152,7 @@ export function PurchaseSeatsModal({
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Send request for ${formatNumber(
|
||||
<span className="tabular-nums">{`Send request for ${formatNumber(
|
||||
amountValue
|
||||
)}`}</span>
|
||||
</Button>
|
||||
@@ -1168,7 +1166,7 @@ export function PurchaseSeatsModal({
|
||||
disabled={isLoading || state === "need_to_remove_members"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Remove ${formatNumber(
|
||||
<span className="tabular-nums">{`Remove ${formatNumber(
|
||||
extraSeats - amountValue
|
||||
)} ${extraSeats - amountValue === 1 ? "seat" : "seats"}`}</span>
|
||||
</Button>
|
||||
@@ -1182,7 +1180,7 @@ export function PurchaseSeatsModal({
|
||||
disabled={isLoading || state === "no_change"}
|
||||
LeadingIcon={isLoading ? SpinnerWhite : undefined}
|
||||
>
|
||||
<span className="tabular-nums text-text-bright">{`Purchase ${formatNumber(
|
||||
<span className="tabular-nums">{`Purchase ${formatNumber(
|
||||
amountValue - extraSeats
|
||||
)} ${amountValue - extraSeats === 1 ? "seat" : "seats"}`}</span>
|
||||
</Button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -547,7 +547,7 @@ function ConfirmDialog({
|
||||
>
|
||||
<div className="font-sans text-sm text-text-bright">{change.key}</div>
|
||||
{change.type === "added" && (
|
||||
<div className="mt-1 text-green-400">+ {change.newVal}</div>
|
||||
<div className="mt-1 text-green-400 system:text-green-700">+ {change.newVal}</div>
|
||||
)}
|
||||
{change.type === "removed" && (
|
||||
<div className="mt-1 text-red-400">- {change.oldVal} (unset)</div>
|
||||
@@ -555,7 +555,7 @@ function ConfirmDialog({
|
||||
{change.type === "changed" && (
|
||||
<>
|
||||
<div className="mt-1 text-red-400">- {change.oldVal}</div>
|
||||
<div className="text-green-400">+ {change.newVal}</div>
|
||||
<div className="text-green-400 system:text-green-700">+ {change.newVal}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -313,11 +313,9 @@ export default function LoginPage() {
|
||||
{isEmailLoading ? (
|
||||
<Spinner className="mr-2 size-5" color="white" />
|
||||
) : (
|
||||
<EnvelopeIcon className="mr-2 size-5 text-text-bright" />
|
||||
<EnvelopeIcon className="mr-2 size-5" />
|
||||
)}
|
||||
<span className="text-text-bright">
|
||||
{isEmailLoading ? "Sending…" : "Continue with Email"}
|
||||
</span>
|
||||
<span>{isEmailLoading ? "Sending…" : "Continue with Email"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,11 +253,7 @@ export default function LoginMfaPage() {
|
||||
data-action="verify recovery code"
|
||||
>
|
||||
{isLoading ? <Spinner className="mr-2 size-5" color="white" /> : null}
|
||||
{isLoading ? (
|
||||
<span className="text-text-bright">Verifying…</span>
|
||||
) : (
|
||||
<span className="text-text-bright">Verify</span>
|
||||
)}
|
||||
{isLoading ? <span>Verifying…</span> : <span>Verify</span>}
|
||||
</Button>
|
||||
{typeof mfaError === "string" && <FormError>{mfaError}</FormError>}
|
||||
</Fieldset>
|
||||
@@ -305,11 +301,7 @@ export default function LoginMfaPage() {
|
||||
data-action="verify mfa code"
|
||||
>
|
||||
{isLoading ? <Spinner className="mr-2 size-5" color="white" /> : null}
|
||||
{isLoading ? (
|
||||
<span className="text-text-bright">Verifying…</span>
|
||||
) : (
|
||||
<span className="text-text-bright">Verify</span>
|
||||
)}
|
||||
{isLoading ? <span>Verifying…</span> : <span>Verify</span>}
|
||||
</Button>
|
||||
{typeof mfaError === "string" && <FormError>{mfaError}</FormError>}
|
||||
</Fieldset>
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function LoginSsoPage() {
|
||||
data-action="continue with sso"
|
||||
>
|
||||
{isLoading && <Spinner className="mr-2 size-5" color="white" />}
|
||||
<span className="text-text-bright">
|
||||
<span>
|
||||
{isLoading
|
||||
? "Redirecting…"
|
||||
: reason === "expired"
|
||||
|
||||
@@ -138,11 +138,7 @@ export function MfaDisableDialog({
|
||||
</Button>
|
||||
<Button type="submit" variant="primary/medium" disabled={isSubmitting}>
|
||||
{isSubmitting ? <Spinner className="mr-2 size-5" color="white" /> : null}
|
||||
{isSubmitting ? (
|
||||
<span className="text-text-bright">Disabling…</span>
|
||||
) : (
|
||||
<span className="text-text-bright">Disable MFA</span>
|
||||
)}
|
||||
{isSubmitting ? <span>Disabling…</span> : <span>Disable MFA</span>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
|
||||
+5
-3
@@ -449,9 +449,11 @@ function RunBody({
|
||||
<Header2
|
||||
className={cn(
|
||||
"overflow-x-hidden",
|
||||
run.isAgentRun ? "text-agents" : run.isScheduled ? "text-schedules" : "text-blue-500",
|
||||
// System themes: monochrome title, the task icon keeps the color
|
||||
"system:text-text-bright"
|
||||
// The run-type accents are drawn for 3:1 as icons; as 16px text the
|
||||
// tasks and agents blues fall under 4.5:1 on the light themes, so
|
||||
// the title takes the text colour there and the icon carries type.
|
||||
run.isAgentRun ? "text-agents" : run.isScheduled ? "text-schedules" : "text-tasks",
|
||||
"light:text-text-bright"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
|
||||
@@ -29,7 +29,7 @@ import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { TextArea } from "~/components/primitives/TextArea";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { TextLink, textLinkClassName } from "~/components/primitives/TextLink";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { resolveOrgIdFromSlug } from "~/models/organization.server";
|
||||
@@ -47,6 +47,11 @@ function WhiteSpinnerIcon() {
|
||||
return <Spinner color="white" />;
|
||||
}
|
||||
|
||||
/** A white spinner would vanish on the secondary button's light surface. */
|
||||
function InheritSpinnerIcon() {
|
||||
return <Spinner color="inherit" />;
|
||||
}
|
||||
|
||||
const Params = z.object({
|
||||
organizationSlug: z.string(),
|
||||
});
|
||||
@@ -533,7 +538,7 @@ export function TierHobby({
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
disabled={isLoading}
|
||||
LeadingIcon={isLoading ? WhiteSpinnerIcon : undefined}
|
||||
LeadingIcon={isLoading ? InheritSpinnerIcon : undefined}
|
||||
form="subscribe-hobby"
|
||||
>
|
||||
{`Downgrade to ${plan.title}`}
|
||||
@@ -608,9 +613,7 @@ export function TierHobby({
|
||||
<Feedback
|
||||
defaultValue="hipaa"
|
||||
button={
|
||||
<span className="cursor-pointer underline decoration-text-faint underline-offset-4 transition hover:decoration-text-bright">
|
||||
Request a BAA
|
||||
</span>
|
||||
<span className={cn(textLinkClassName(), "cursor-pointer")}>Request a BAA</span>
|
||||
}
|
||||
/>
|
||||
</HIPAAAddOn>
|
||||
@@ -763,9 +766,7 @@ export function TierPro({
|
||||
<Feedback
|
||||
defaultValue="hipaa"
|
||||
button={
|
||||
<span className="cursor-pointer underline decoration-text-faint underline-offset-4 transition hover:decoration-text-bright">
|
||||
Request a BAA
|
||||
</span>
|
||||
<span className={cn(textLinkClassName(), "cursor-pointer")}>Request a BAA</span>
|
||||
}
|
||||
/>
|
||||
</HIPAAAddOn>
|
||||
@@ -823,9 +824,7 @@ export function TierEnterprise() {
|
||||
<Feedback
|
||||
defaultValue="hipaa"
|
||||
button={
|
||||
<span className="cursor-pointer underline decoration-text-faint underline-offset-4 transition hover:decoration-text-bright">
|
||||
Request a BAA
|
||||
</span>
|
||||
<span className={cn(textLinkClassName(), "cursor-pointer")}>Request a BAA</span>
|
||||
}
|
||||
/>
|
||||
</HIPAAAddOn>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { updateThemePreference } from "~/services/dashboardPreferences.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { ThemePreference } from "~/utils/themePreference";
|
||||
import { cachedFlag } from "~/v3/featureFlags.server";
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
|
||||
// Same gate as the account page: not writable while the flag is off.
|
||||
const showThemeSwitcher =
|
||||
user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false }));
|
||||
if (!showThemeSwitcher) {
|
||||
return json({ success: false, error: "Not available" }, { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
// Strict, not normalized: an unknown value must fail rather than reset.
|
||||
const theme = ThemePreference.safeParse(formData.get("theme"));
|
||||
if (!theme.success) {
|
||||
return json({ success: false, error: "Invalid theme" }, { status: 400 });
|
||||
}
|
||||
|
||||
await updateThemePreference({ user, theme: theme.data });
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BookOpenIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "~/components/primitives/Accordion";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { StoryPage, StorySection } from "../storybook/StoryKit";
|
||||
|
||||
export default function Story_() {
|
||||
return (
|
||||
<StoryPage
|
||||
componentNames={["Accordion.tsx"]}
|
||||
title="Accordion"
|
||||
description="Collapsible sections built on Radix Accordion."
|
||||
>
|
||||
<StorySection title="Single, collapsible">
|
||||
<div className="max-w-xl rounded-sm border border-grid-dimmed p-2">
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="what">
|
||||
<AccordionTrigger>What counts as a run?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Paragraph variant="small">
|
||||
Every task execution is a run, including retries and child tasks triggered from a
|
||||
parent.
|
||||
</Paragraph>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="limits">
|
||||
<AccordionTrigger>How do concurrency limits work?</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Paragraph variant="small">
|
||||
Each environment has a concurrency limit; runs beyond it queue until a slot frees
|
||||
up.
|
||||
</Paragraph>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="Multiple open, with leading icons">
|
||||
<div className="max-w-xl rounded-sm border border-grid-dimmed p-2">
|
||||
<Accordion type="multiple" defaultValue={["docs"]}>
|
||||
<AccordionItem value="docs">
|
||||
<AccordionTrigger leadingIcon={BookOpenIcon} leadingIconClassName="text-blue-500">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Paragraph variant="small">Open by default via defaultValue.</Paragraph>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="settings">
|
||||
<AccordionTrigger leadingIcon={Cog8ToothIcon} leadingIconClassName="text-text-dimmed">
|
||||
Advanced settings
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<Paragraph variant="small">
|
||||
Both items can be open at once with type="multiple".
|
||||
</Paragraph>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
);
|
||||
}
|
||||
@@ -83,5 +83,7 @@ const STATES: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return <GalleryPage page="investigation" states={STATES} />;
|
||||
return (
|
||||
<GalleryPage page="investigation" states={STATES} componentNames={["InvestigationCard.tsx"]} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,5 +28,5 @@ const STATES: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return <GalleryPage page="report" states={STATES} />;
|
||||
return <GalleryPage page="report" states={STATES} componentNames={["ReportView.tsx"]} />;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { safeParseTriggerUri } from "@internal/dashboard-agent-contracts";
|
||||
import { Header1, Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import {
|
||||
GALLERY_PAGES,
|
||||
groupsOnPage,
|
||||
@@ -54,24 +55,6 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeToggle() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* classic is still the default theme for most users, so it's in the pack */}
|
||||
{(["classic", "dark", "light"] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
onClick={() => document.documentElement.setAttribute("data-theme", theme)}
|
||||
className="rounded border border-border-bright bg-background-bright px-2 py-1 text-xs text-text-dimmed transition hover:text-text-bright"
|
||||
>
|
||||
{theme}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PageLinks({ page }: { page: GalleryPageId }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -120,9 +103,12 @@ function Nav({ page }: { page: GalleryPageId }) {
|
||||
export function GalleryPage({
|
||||
page,
|
||||
states,
|
||||
componentNames,
|
||||
}: {
|
||||
page: GalleryPageId;
|
||||
states: Record<string, React.ReactNode>;
|
||||
/** Component files this page covers, shown copyable under the title. */
|
||||
componentNames?: string[];
|
||||
}) {
|
||||
const meta = GALLERY_PAGES.find((entry) => entry.id === page)!;
|
||||
const sections = sectionsOnPage(page);
|
||||
@@ -134,8 +120,8 @@ export function GalleryPage({
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<Header1>Trigger Agent — {meta.title}</Header1>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{componentNames && componentNames.length > 0 && <ComponentNames names={componentNames} />}
|
||||
<PageLinks page={page} />
|
||||
<Paragraph variant="small">
|
||||
{meta.blurb} {sections.length} states, rendered in isolation at panel width (380px) from
|
||||
@@ -145,8 +131,8 @@ export function GalleryPage({
|
||||
</Paragraph>
|
||||
<Paragraph variant="extra-small">
|
||||
Run ids, queues, errors and reports are fabricated. Deep links resolve inside a project,
|
||||
so here they render as plain text or navigate nowhere. The theme buttons flip{" "}
|
||||
<code className="font-mono text-xs">data-theme</code> on the root element.
|
||||
so here they render as plain text or navigate nowhere. Use the theme control in the
|
||||
storybook header to switch themes.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -333,5 +333,15 @@ const STATES: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return <GalleryPage page="chat" states={STATES} />;
|
||||
return (
|
||||
<GalleryPage
|
||||
page="chat"
|
||||
states={STATES}
|
||||
componentNames={[
|
||||
"DashboardAgentComposer.tsx",
|
||||
"DashboardAgentMessages.tsx",
|
||||
"DashboardAgentHero.tsx",
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,5 +120,11 @@ const STATES: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return <GalleryPage page="view-blocks" states={STATES} />;
|
||||
return (
|
||||
<GalleryPage
|
||||
page="view-blocks"
|
||||
states={STATES}
|
||||
componentNames={["AgentChart.tsx", "RunDiagnosisCard.tsx", "agent-card.tsx"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,5 +120,11 @@ const STATES: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
export default function Story() {
|
||||
return <GalleryPage page="watch" states={STATES} />;
|
||||
return (
|
||||
<GalleryPage
|
||||
page="watch"
|
||||
states={STATES}
|
||||
componentNames={["WatchCard.tsx", "WatchResultBlock.tsx", "WatchWakeToast.tsx"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { LogoIcon } from "~/components/LogoIcon";
|
||||
import { Button, type ButtonVariant } from "~/components/primitives/Buttons";
|
||||
@@ -26,6 +27,9 @@ import {
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div className="px-4 pt-4">
|
||||
<ComponentNames names={["AgentDotMatrix.tsx"]} />
|
||||
</div>
|
||||
<div className="flex max-w-3xl flex-col gap-1">
|
||||
<Header1>Trigger Agent — Icons & Buttons</Header1>
|
||||
<Paragraph variant="small">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import { useState } from "react";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
@@ -166,6 +167,9 @@ export default function Story() {
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="px-4 pt-4">
|
||||
<ComponentNames names={["Resizable.tsx", "PropertyTable.tsx"]} />
|
||||
</div>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="animated-panel-main" min="200px">
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import {
|
||||
Avatar,
|
||||
avatarIcons,
|
||||
@@ -15,6 +16,9 @@ const avatars: IconAvatar[] = Object.entries(avatarIcons).map(([iconName], index
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="flex h-full gap-12 bg-black p-12">
|
||||
<div className="px-4 pt-4">
|
||||
<ComponentNames names={["Avatar.tsx"]} />
|
||||
</div>
|
||||
{/* Left grid - size-8 */}
|
||||
<div className="flex-1">
|
||||
<h2 className="mb-4 text-lg font-semibold text-white">Size 8</h2>
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { NewBadge } from "~/components/FeatureBadges";
|
||||
import { Story, StoryGrid, StoryPage, StorySection } from "../storybook/StoryKit";
|
||||
|
||||
export default function Story() {
|
||||
const VARIANTS = ["default", "extra-small", "small", "outline-rounded", "rounded"] as const;
|
||||
|
||||
export default function Story_() {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-y-8 p-8">
|
||||
<Badge>Default</Badge>
|
||||
<Badge variant="rounded">3</Badge>
|
||||
<Badge variant="outline-rounded">Outline rounded</Badge>
|
||||
</div>
|
||||
<StoryPage
|
||||
title="Badges"
|
||||
componentNames={["Badge.tsx", "FeatureBadges.tsx"]}
|
||||
description="Every badge variant, plus the feature badges used in the side menu."
|
||||
>
|
||||
<StorySection title="Variants" componentName="Badge.tsx">
|
||||
<StoryGrid min="12rem">
|
||||
{VARIANTS.map((variant) => (
|
||||
<Story key={variant} label={variant}>
|
||||
<Badge variant={variant}>{variant === "rounded" ? "3" : variant}</Badge>
|
||||
</Story>
|
||||
))}
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="In context" description="Counts and labels beside text.">
|
||||
<div className="flex flex-wrap items-center gap-4 rounded-sm border border-grid-dimmed p-3">
|
||||
<span className="flex items-center gap-2 text-sm text-text-bright">
|
||||
Runs <Badge variant="rounded">12</Badge>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-sm text-text-bright">
|
||||
Status <Badge variant="extra-small">Live</Badge>
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-sm text-text-bright">
|
||||
Plan <Badge variant="outline-rounded">Free</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="Feature badges" componentName="FeatureBadges.tsx">
|
||||
<StoryGrid min="12rem">
|
||||
<Story label="NewBadge">
|
||||
<NewBadge />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,424 +1,203 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
CheckIcon,
|
||||
BookOpenIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
GlobeAltIcon,
|
||||
LightBulbIcon,
|
||||
NoSymbolIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { EnvelopeIcon } from "@heroicons/react/24/solid";
|
||||
import {
|
||||
AirtableIcon,
|
||||
GitHubDarkIcon,
|
||||
GitHubLightIcon,
|
||||
SlackIcon,
|
||||
} from "@trigger.dev/companyicons";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Header1, Header3 } from "~/components/primitives/Headers";
|
||||
import { AirtableIcon, GitHubLightIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Story, StoryGrid, StoryPage, StorySection, StorySubSection } from "../storybook/StoryKit";
|
||||
|
||||
export default function Story() {
|
||||
const isSelected = true;
|
||||
const disabled = true;
|
||||
/* Driven off the variant list so the page can't drift from the component: every
|
||||
family × size combination renders, plus the standalone menu-item variants. */
|
||||
const FAMILIES = [
|
||||
"primary",
|
||||
"secondary",
|
||||
"tertiary",
|
||||
"minimal",
|
||||
"danger",
|
||||
"warning",
|
||||
"docs",
|
||||
"ask-trigger",
|
||||
] as const;
|
||||
|
||||
const SIZES = ["small", "medium", "large", "extra-large"] as const;
|
||||
|
||||
/** ask-trigger has no extra-large; every other family covers all four sizes. */
|
||||
function sizesFor(family: (typeof FAMILIES)[number]) {
|
||||
return family === "ask-trigger" ? SIZES.filter((s) => s !== "extra-large") : SIZES;
|
||||
}
|
||||
|
||||
type ButtonVariant = Parameters<typeof Button>[0]["variant"];
|
||||
|
||||
const MENU_VARIANTS = ["menu-item", "small-menu-item", "small-menu-sub-item"] as const;
|
||||
|
||||
export default function Story_() {
|
||||
return (
|
||||
<div className="bg-background-dimmed p-12">
|
||||
<Header1 className="mb-2">Small buttons</Header1>
|
||||
<div className="grid grid-cols-4 gap-8 border-b border-grid-bright pb-8">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Basic</Header3>
|
||||
<Button variant="primary/small">Primary button</Button>
|
||||
<Button variant="secondary/small">Secondary button</Button>
|
||||
<Button variant="tertiary/small">Tertiary button</Button>
|
||||
<Button variant="minimal/small">Minimal button</Button>
|
||||
<Button variant="danger/small">Danger button</Button>
|
||||
<Button variant="warning/small">Warning button</Button>
|
||||
<StoryPage
|
||||
title="Buttons"
|
||||
componentNames={["Buttons.tsx"]}
|
||||
description="Every variant of Button and LinkButton: all 8 families across all sizes, plus the menu-item variants and every state."
|
||||
>
|
||||
<StorySection
|
||||
title="All families and sizes"
|
||||
description="One row per family; a column per size."
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{FAMILIES.map((family) => (
|
||||
<div key={family} className="rounded-sm border border-grid-dimmed p-3">
|
||||
<Paragraph variant="extra-extra-small/caps" className="mb-2 text-text-dimmed">
|
||||
{family}
|
||||
</Paragraph>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{sizesFor(family).map((size) => (
|
||||
<Button key={size} variant={`${family}/${size}` as ButtonVariant}>
|
||||
{size}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Icon left</Header3>
|
||||
<Button variant="primary/small" LeadingIcon={ArrowLeftIcon}>
|
||||
Primary button
|
||||
</Button>
|
||||
<Button variant="secondary/small" LeadingIcon={ArrowLeftIcon}>
|
||||
Secondary button
|
||||
</Button>
|
||||
<Button variant="tertiary/small" LeadingIcon={ArrowLeftIcon}>
|
||||
Tertiary button
|
||||
</Button>
|
||||
<Button variant="minimal/small" LeadingIcon={ArrowLeftIcon}>
|
||||
Minimal button
|
||||
</Button>
|
||||
<Button variant="danger/small" LeadingIcon={ArrowLeftIcon}>
|
||||
Danger button
|
||||
</Button>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="Icon-only and menu variants">
|
||||
<StoryGrid min="14rem">
|
||||
<Story label="secondary/small-icon">
|
||||
<Button variant="secondary/small-icon" LeadingIcon={PlusIcon} />
|
||||
</Story>
|
||||
{MENU_VARIANTS.map((variant) => (
|
||||
<Story key={variant} label={variant}>
|
||||
<Button variant={variant} LeadingIcon={BookOpenIcon}>
|
||||
Menu label
|
||||
</Button>
|
||||
</Story>
|
||||
))}
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection
|
||||
title="States"
|
||||
description="Each family's medium size in every state it can be in."
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{(["Default", "Disabled", "Loading", "With shortcut"] as const).map((state) => (
|
||||
<StorySubSection key={state} title={state}>
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-sm border border-grid-dimmed p-3">
|
||||
{FAMILIES.map((family) => (
|
||||
<Button
|
||||
key={family}
|
||||
variant={`${family}/medium` as ButtonVariant}
|
||||
disabled={state === "Disabled"}
|
||||
isLoading={state === "Loading"}
|
||||
shortcut={
|
||||
state === "With shortcut" ? { key: "e", modifiers: ["mod"] } : undefined
|
||||
}
|
||||
>
|
||||
{family}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</StorySubSection>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Icon right</Header3>
|
||||
<Button variant="primary/small" TrailingIcon={ArrowRightIcon}>
|
||||
Primary button
|
||||
</Button>
|
||||
<Button variant="secondary/small" TrailingIcon={ArrowRightIcon}>
|
||||
Secondary button
|
||||
</Button>
|
||||
<Button variant="tertiary/small" TrailingIcon={ArrowRightIcon}>
|
||||
Tertiary button
|
||||
</Button>
|
||||
<Button variant="minimal/small" TrailingIcon={ArrowRightIcon}>
|
||||
Minimal button
|
||||
</Button>
|
||||
<Button variant="danger/small" TrailingIcon={ArrowRightIcon}>
|
||||
Danger button
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Shortcut</Header3>
|
||||
<Button variant="primary/small" shortcut={{ key: "s", modifiers: ["mod"] }}>
|
||||
Primary button
|
||||
</Button>
|
||||
<Button variant="secondary/small" shortcut={{ key: "f" }}>
|
||||
Secondary button
|
||||
</Button>
|
||||
<Button variant="tertiary/small" shortcut={{ key: "i" }}>
|
||||
Tertiary button
|
||||
</Button>
|
||||
<Button variant="minimal/small" shortcut={{ key: "i" }}>
|
||||
Minimal button
|
||||
</Button>
|
||||
<Button variant="danger/small" shortcut={{ key: "s", modifiers: ["mod"] }}>
|
||||
Danger button
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Named icon</Header3>
|
||||
<Button LeadingIcon={AirtableIcon} variant="primary/small">
|
||||
Connect to Airtable
|
||||
</Button>
|
||||
<Button LeadingIcon={GitHubDarkIcon} variant="primary/small">
|
||||
Connect to GitHub
|
||||
</Button>
|
||||
<Button TrailingIcon={SlackIcon} variant="secondary/small">
|
||||
Connect to Slack
|
||||
</Button>
|
||||
<Button TrailingIcon={ExclamationTriangleIcon} variant="secondary/small">
|
||||
Trailing icon
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Loading</Header3>
|
||||
<Button
|
||||
variant="primary/small"
|
||||
LeadingIcon={<Spinner color="white" className="size-3.5" />}
|
||||
>
|
||||
Loading Primary…
|
||||
</Button>
|
||||
<Button variant="secondary/small" LeadingIcon={Spinner}>
|
||||
Loading Secondary…
|
||||
</Button>
|
||||
<Button variant="tertiary/small" LeadingIcon={Spinner}>
|
||||
Loading Tertiary…
|
||||
</Button>
|
||||
<Button variant="minimal/small" LeadingIcon={Spinner}>
|
||||
Loading Minimal…
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger/small"
|
||||
LeadingIcon={<Spinner color="white" className="size-3.5" />}
|
||||
>
|
||||
Loading Danger…
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Disabled</Header3>
|
||||
<Button variant="primary/small" disabled>
|
||||
Primary button
|
||||
</Button>
|
||||
<Button variant="secondary/small" disabled>
|
||||
Secondary button
|
||||
</Button>
|
||||
<Button variant="tertiary/small" disabled>
|
||||
Tertiary button
|
||||
</Button>
|
||||
<Button variant="minimal/small" disabled>
|
||||
Minimal button
|
||||
</Button>
|
||||
<Button variant="danger/small" disabled>
|
||||
Danger button
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Icon only</Header3>
|
||||
<Button variant="primary/small" LeadingIcon={ArrowRightIcon} />
|
||||
<Button variant="secondary/small" LeadingIcon={LightBulbIcon} />
|
||||
<Button variant="tertiary/small" LeadingIcon={ExclamationTriangleIcon} />
|
||||
<Button variant="minimal/small" LeadingIcon={ExclamationTriangleIcon} />
|
||||
<Button variant="danger/small" LeadingIcon={ExclamationTriangleIcon} />
|
||||
</div>
|
||||
</div>
|
||||
<Header1 className="mb-2 mt-8">Medium buttons</Header1>
|
||||
<div className="grid grid-cols-4 gap-8 border-b border-grid-bright pb-8">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Basic</Header3>
|
||||
<Button variant="primary/medium">Primary button</Button>
|
||||
<Button variant="secondary/medium">Secondary button</Button>
|
||||
<Button variant="tertiary/medium">Tertiary button</Button>
|
||||
<Button variant="danger/medium">Danger button</Button>
|
||||
<Button variant="warning/medium">Warning button</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Icon left</Header3>
|
||||
<Button variant="primary/medium" LeadingIcon={ArrowLeftIcon}>
|
||||
Primary button
|
||||
</Button>
|
||||
<Button variant="secondary/medium" LeadingIcon={ArrowLeftIcon}>
|
||||
Secondary button
|
||||
</Button>
|
||||
<Button variant="tertiary/medium" LeadingIcon={ArrowLeftIcon}>
|
||||
Tertiary button
|
||||
</Button>
|
||||
<Button variant="minimal/medium" LeadingIcon={ArrowLeftIcon}>
|
||||
Minimal button
|
||||
</Button>
|
||||
<Button variant="danger/medium" LeadingIcon={ArrowLeftIcon}>
|
||||
Danger button
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Icon right</Header3>
|
||||
<Button variant="primary/medium" TrailingIcon={ArrowRightIcon}>
|
||||
Primary button
|
||||
</Button>
|
||||
<Button variant="secondary/medium" TrailingIcon={ArrowRightIcon}>
|
||||
Secondary button
|
||||
</Button>
|
||||
<Button variant="tertiary/medium" TrailingIcon={ArrowRightIcon}>
|
||||
Tertiary button
|
||||
</Button>
|
||||
<Button variant="minimal/medium" TrailingIcon={ArrowRightIcon}>
|
||||
Minimal button
|
||||
</Button>
|
||||
<Button variant="danger/medium" TrailingIcon={ArrowRightIcon}>
|
||||
Danger button
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Shortcut</Header3>
|
||||
<Button variant="primary/medium" shortcut={{ key: "s", modifiers: ["mod"] }}>
|
||||
Primary button
|
||||
</Button>
|
||||
<Button variant="secondary/medium" shortcut={{ key: "s", modifiers: ["mod"] }}>
|
||||
Secondary button
|
||||
</Button>
|
||||
<Button variant="tertiary/medium" shortcut={{ key: "s", modifiers: ["mod"] }}>
|
||||
Tertiary button
|
||||
</Button>
|
||||
<Button variant="minimal/medium" shortcut={{ key: "s", modifiers: ["mod"] }}>
|
||||
Minimal button
|
||||
</Button>
|
||||
<Button variant="danger/medium" shortcut={{ key: "s", modifiers: ["mod"] }}>
|
||||
Danger button
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Named icon</Header3>
|
||||
<Button LeadingIcon={AirtableIcon} variant="primary/medium">
|
||||
Connect to Airtable
|
||||
</Button>
|
||||
<Button LeadingIcon={GitHubDarkIcon} variant="primary/medium">
|
||||
Connect to GitHub
|
||||
</Button>
|
||||
<Button TrailingIcon={SlackIcon} variant="secondary/medium">
|
||||
Connect to Slack
|
||||
</Button>
|
||||
<Button TrailingIcon={ExclamationTriangleIcon} variant="secondary/medium">
|
||||
Connect to Slack
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Loading</Header3>
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
LeadingIcon={<Spinner color="white" className="size-4" />}
|
||||
>
|
||||
Loading Primary…
|
||||
</Button>
|
||||
<Button variant="secondary/medium" LeadingIcon={Spinner}>
|
||||
Loading Secondary…
|
||||
</Button>
|
||||
<Button variant="tertiary/medium" LeadingIcon={Spinner}>
|
||||
Loading Tertiary…
|
||||
</Button>
|
||||
<Button variant="minimal/medium" LeadingIcon={Spinner}>
|
||||
Loading Minimal…
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger/medium"
|
||||
LeadingIcon={<Spinner color="white" className="size-4" />}
|
||||
>
|
||||
Loading Danger…
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Disabled</Header3>
|
||||
<Button variant="primary/medium" disabled>
|
||||
Primary button
|
||||
</Button>
|
||||
<Button variant="secondary/medium" disabled>
|
||||
Secondary button
|
||||
</Button>
|
||||
<Button variant="tertiary/medium" disabled>
|
||||
Tertiary button
|
||||
</Button>
|
||||
<Button variant="tertiary/medium" disabled>
|
||||
Minimal button
|
||||
</Button>
|
||||
<Button variant="danger/medium" disabled>
|
||||
Danger button
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Icon only</Header3>
|
||||
<Button variant="primary/medium" LeadingIcon={ArrowRightIcon} />
|
||||
<Button variant="secondary/medium" LeadingIcon={LightBulbIcon} />
|
||||
<Button variant="tertiary/medium" LeadingIcon={ExclamationTriangleIcon} />
|
||||
<Button variant="minimal/medium" LeadingIcon={ExclamationTriangleIcon} />
|
||||
<Button variant="danger/medium" LeadingIcon={ExclamationTriangleIcon} />
|
||||
</div>
|
||||
</div>
|
||||
<Header1 className="mb-2 mt-8">Large buttons</Header1>
|
||||
<div className="grid grid-cols-1 gap-8 pb-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button variant="primary/large" fullWidth>
|
||||
<GitHubLightIcon className={"mr-1.5 size-[1.2rem]"} />
|
||||
<span className="text-text-bright">Continue with GitHub</span>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="Icons" description="Leading, trailing, both, and brand icons.">
|
||||
<StoryGrid min="15rem">
|
||||
<Story label="LeadingIcon">
|
||||
<Button variant="secondary/medium" LeadingIcon={ArrowLeftIcon}>
|
||||
Back
|
||||
</Button>
|
||||
<Button variant="secondary/large" fullWidth>
|
||||
<EnvelopeIcon className={"mr-1.5 size-[1.2rem] text-text-bright transition"} />
|
||||
<span className="text-text-bright">Continue with Email</span>
|
||||
</Story>
|
||||
<Story label="TrailingIcon">
|
||||
<Button variant="secondary/medium" TrailingIcon={ArrowRightIcon}>
|
||||
Next
|
||||
</Button>
|
||||
<Button variant="tertiary/large" fullWidth>
|
||||
<GitHubLightIcon className={"mr-1.5 size-[1.2rem]"} />
|
||||
<span className="text-text-bright">Continue with GitHub</span>
|
||||
</Story>
|
||||
<Story label="Both">
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={ExclamationTriangleIcon}
|
||||
TrailingIcon={ArrowRightIcon}
|
||||
>
|
||||
Review
|
||||
</Button>
|
||||
<Button variant="danger/large" fullWidth>
|
||||
<TrashIcon
|
||||
className={
|
||||
"mr-1.5 h-4 w-4 text-text-bright transition group-hover:text-text-bright"
|
||||
}
|
||||
/>
|
||||
<span className="text-text-bright">This is a delete button</span>
|
||||
</Story>
|
||||
<Story label="Danger + icon">
|
||||
<Button variant="danger/medium" LeadingIcon={TrashIcon}>
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="warning/large" fullWidth>
|
||||
<TrashIcon className="mr-1.5 h-4 w-4" />
|
||||
<span>This is a warning button</span>
|
||||
</Story>
|
||||
<Story label="Icon only">
|
||||
<Button variant="minimal/medium" LeadingIcon={ExclamationTriangleIcon} />
|
||||
</Story>
|
||||
<Story label="Brand icon (Slack)">
|
||||
<Button variant="secondary/medium" LeadingIcon={SlackIcon}>
|
||||
Connect to Slack
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Header1 className="mb-2 mt-8">Extra Large buttons</Header1>
|
||||
<div className="grid grid-cols-1 gap-8 pb-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Button variant="primary/extra-large" fullWidth>
|
||||
<GitHubLightIcon className={"mr-1.5 size-5"} />
|
||||
<span className="text-text-bright">Continue with GitHub</span>
|
||||
</Story>
|
||||
<Story label="Brand icon (GitHub)">
|
||||
<Button variant="secondary/medium" LeadingIcon={GitHubLightIcon}>
|
||||
Connect to GitHub
|
||||
</Button>
|
||||
<Button variant="secondary/extra-large" fullWidth>
|
||||
<EnvelopeIcon className={"mr-1.5 h-5 w-5 text-text-bright transition"} />
|
||||
<span className="text-text-bright">Continue with Email</span>
|
||||
</Story>
|
||||
<Story label="Brand icon (Airtable)">
|
||||
<Button variant="secondary/medium" LeadingIcon={AirtableIcon}>
|
||||
Connect to Airtable
|
||||
</Button>
|
||||
<Button variant="tertiary/extra-large" fullWidth>
|
||||
<GitHubLightIcon className={"mr-1.5 size-5"} />
|
||||
<span className="text-text-bright">Continue with GitHub</span>
|
||||
</Story>
|
||||
<Story label="Explicit Spinner child">
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
LeadingIcon={<Spinner color="inherit" className="size-4" />}
|
||||
>
|
||||
Working…
|
||||
</Button>
|
||||
<Button variant="danger/extra-large" fullWidth>
|
||||
<TrashIcon
|
||||
className={"mr-1.5 size-5 text-text-bright transition group-hover:text-text-bright"}
|
||||
/>
|
||||
<span className="text-text-bright">This is a delete button</span>
|
||||
</Button>
|
||||
<Button variant="warning/extra-large" fullWidth>
|
||||
<TrashIcon
|
||||
className={"mr-1.5 size-5 text-text-bright transition group-hover:text-text-bright"}
|
||||
/>
|
||||
<span>This is a warning button</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Header1 className="mb-2 mt-8">Menu items</Header1>
|
||||
<div className="grid grid-cols-1">
|
||||
<div className="flex flex-col items-start gap-1 rounded border border-background-bright bg-background-dimmed p-1">
|
||||
<Button variant="menu-item" fullWidth textAlignLeft LeadingIcon={FolderIcon}>
|
||||
Acme Inc.
|
||||
</Button>
|
||||
<Button variant="menu-item" fullWidth textAlignLeft LeadingIcon={PlusIcon}>
|
||||
New Project
|
||||
</Button>
|
||||
<Button
|
||||
variant="menu-item"
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="Layout" description="fullWidth and left-aligned text.">
|
||||
<div className="flex max-w-md flex-col gap-3">
|
||||
<Button variant="primary/medium" fullWidth>
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={GlobeAltIcon}
|
||||
TrailingIcon={isSelected ? CheckIcon : undefined}
|
||||
className={
|
||||
isSelected ? "bg-background-hover group-hover:bg-background-hover" : undefined
|
||||
}
|
||||
>
|
||||
Item enabled
|
||||
</Button>
|
||||
<Button variant="menu-item" fullWidth textAlignLeft>
|
||||
When a Stripe payment fails re-engage the customer
|
||||
</Button>
|
||||
<Button variant="menu-item" fullWidth textAlignLeft LeadingIcon={Spinner}>
|
||||
In Progress
|
||||
</Button>
|
||||
<Button
|
||||
variant="menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={ArrowUturnLeftIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
>
|
||||
Latest run payload
|
||||
</Button>
|
||||
<Button
|
||||
variant="menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={NoSymbolIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
disabled
|
||||
className={disabled ? "group-hover:bg-transparent" : undefined}
|
||||
>
|
||||
Disabled menu item
|
||||
<Button variant="secondary/medium" fullWidth textAlignLeft LeadingIcon={PlusIcon}>
|
||||
fullWidth + textAlignLeft
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Header1 className="mb-2 mt-8">Ask Trigger button</Header1>
|
||||
<div className="grid grid-cols-4 gap-8 border-b border-grid-bright pb-8">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Sizes</Header3>
|
||||
<Button variant="ask-trigger/small">Ask Trigger</Button>
|
||||
<Button variant="ask-trigger/medium">Ask Trigger</Button>
|
||||
<Button variant="ask-trigger/large">Ask Trigger</Button>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<Header3 className="mb-1 uppercase">Disabled</Header3>
|
||||
<Button variant="ask-trigger/small" disabled>
|
||||
Ask Trigger
|
||||
</Button>
|
||||
</div>
|
||||
<div className="col-span-2 self-end text-sm text-text-dimmed">
|
||||
Reserved for the AI agent. The variant supplies the agent logo automatically, so no
|
||||
LeadingIcon is needed. Pass one to animate it. See the "AI agent" story for the live
|
||||
pairing.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</StorySection>
|
||||
|
||||
<StorySection
|
||||
title="LinkButton"
|
||||
componentName="Buttons.tsx"
|
||||
description="Same variants, rendered as an anchor."
|
||||
>
|
||||
<StoryGrid min="15rem">
|
||||
{(["primary", "secondary", "tertiary", "minimal", "docs"] as const).map((family) => (
|
||||
<Story key={family} label={`${family}/medium`}>
|
||||
<LinkButton to="/storybook/buttons" variant={`${family}/medium` as ButtonVariant}>
|
||||
{family}
|
||||
</LinkButton>
|
||||
</Story>
|
||||
))}
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="Tooltip" description="Any button can carry a tooltip.">
|
||||
<StoryGrid min="15rem">
|
||||
<Story label="With tooltip">
|
||||
<Button variant="secondary/medium" tooltip="This explains the button">
|
||||
Hover me
|
||||
</Button>
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import { EnvelopeIcon } from "@heroicons/react/20/solid";
|
||||
import { useState } from "react";
|
||||
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
|
||||
@@ -10,6 +11,9 @@ export default function Story() {
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="px-4 pt-4">
|
||||
<ComponentNames names={["Callout.tsx", "AnimatedCallout.tsx"]} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-y-4 p-4">
|
||||
<Header2>Animated callout</Header2>
|
||||
<Button
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import { ArrowTrendingUpIcon } from "@heroicons/react/20/solid";
|
||||
import { IconTimeline } from "@tabler/icons-react";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -397,6 +398,9 @@ export default function Story() {
|
||||
defaultStartDate={API_DATA.defaultDateRange.startDate}
|
||||
defaultEndDate={API_DATA.defaultDateRange.endDate}
|
||||
>
|
||||
<div className="px-4 pt-4">
|
||||
<ComponentNames names={["Chart.tsx", "ChartCompound.tsx", "BigNumberCard.tsx"]} />
|
||||
</div>
|
||||
<ChartsDashboard />
|
||||
</DateRangeProvider>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
@@ -7,6 +8,9 @@ export default function Story() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-y-8 p-8">
|
||||
<div className="px-4 pt-4">
|
||||
<ComponentNames names={["Checkbox.tsx"]} />
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsDisabled((d) => !d)}
|
||||
variant="primary/medium"
|
||||
@@ -70,6 +74,21 @@ export default function Story() {
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
<CheckboxWithLabel
|
||||
name="Button small checkbox"
|
||||
id="check5"
|
||||
variant="button/small"
|
||||
label="This is a button/small checkbox"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
name="Button small checkbox checked"
|
||||
id="check6"
|
||||
variant="button/small"
|
||||
defaultChecked
|
||||
label="button/small, default checked"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
@@ -7,6 +8,9 @@ import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="flex gap-x-8 p-8">
|
||||
<div className="px-4 pt-4">
|
||||
<ComponentNames names={["ClipboardField.tsx"]} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start whitespace-nowrap">
|
||||
<Header2 className="mb-[2.2rem]">Variants</Header2>
|
||||
<div className="mb-10 space-y-[2.33rem]">
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-y-8 p-8">
|
||||
<div className="px-4 pt-4">
|
||||
<ComponentNames names={["CodeBlock.tsx"]} />
|
||||
</div>
|
||||
<div>
|
||||
<Header2>Inline field</Header2>
|
||||
<CodeBlock code={`{ id: "my-first-job" }`} />
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/* Ratios are read off real DOM nodes, so a column that pins `data-theme` on a
|
||||
wrapper reports that theme's answer without the page having to switch. */
|
||||
|
||||
export type Rgb = { r: number; g: number; b: number; a: number };
|
||||
|
||||
/** WCAG 1.4.11: icons, chart series, borders - anything non-text. */
|
||||
export const NON_TEXT_THRESHOLD = 3;
|
||||
/** WCAG 1.4.3: body-sized text, which is what every status label is. */
|
||||
export const TEXT_THRESHOLD = 4.5;
|
||||
|
||||
let parseContext: CanvasRenderingContext2D | null | undefined;
|
||||
|
||||
function getParseContext() {
|
||||
if (parseContext === undefined) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
parseContext = canvas.getContext("2d", { willReadFrequently: true });
|
||||
}
|
||||
return parseContext;
|
||||
}
|
||||
|
||||
/** Via canvas, not a regex: computed values include `color-mix()` and `oklab()`. */
|
||||
export function parseCssColor(value: string | null | undefined): Rgb | null {
|
||||
if (!value) return null;
|
||||
|
||||
const ctx = getParseContext();
|
||||
if (!ctx) return null;
|
||||
|
||||
// An unparseable assignment leaves fillStyle untouched, so seed it twice.
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillStyle = value;
|
||||
const fromBlack = ctx.fillStyle;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillStyle = value;
|
||||
if (ctx.fillStyle !== fromBlack) return null;
|
||||
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
|
||||
const [r, g, b, alpha] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return { r, g, b, a: alpha / 255 };
|
||||
}
|
||||
|
||||
/** Flatten a translucent color onto whatever sits behind it. */
|
||||
export function compositeOver(fg: Rgb, bg: Rgb): Rgb {
|
||||
if (fg.a >= 1) return fg;
|
||||
return {
|
||||
r: fg.r * fg.a + bg.r * (1 - fg.a),
|
||||
g: fg.g * fg.a + bg.g * (1 - fg.a),
|
||||
b: fg.b * fg.a + bg.b * (1 - fg.a),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks up to the first opaque background, then composites the translucent
|
||||
* layers back down - tinted chips would otherwise report the wrong backdrop.
|
||||
*/
|
||||
export function effectiveBackgroundColor(element: Element | null): Rgb {
|
||||
const layers: Rgb[] = [];
|
||||
let node: Element | null = element;
|
||||
|
||||
while (node) {
|
||||
const color = parseCssColor(getComputedStyle(node).backgroundColor);
|
||||
if (color && color.a > 0) {
|
||||
layers.push(color);
|
||||
if (color.a >= 1) break;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
// Nothing opaque found: fall back to white, the harsher end.
|
||||
let result: Rgb = { r: 255, g: 255, b: 255, a: 1 };
|
||||
for (let i = layers.length - 1; i >= 0; i--) {
|
||||
result = compositeOver(layers[i], result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function channelToLinear(channel: number) {
|
||||
const c = channel / 255;
|
||||
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
export function relativeLuminance({ r, g, b }: Rgb) {
|
||||
return 0.2126 * channelToLinear(r) + 0.7152 * channelToLinear(g) + 0.0722 * channelToLinear(b);
|
||||
}
|
||||
|
||||
export function contrastRatio(a: Rgb, b: Rgb) {
|
||||
const la = relativeLuminance(a);
|
||||
const lb = relativeLuminance(b);
|
||||
const lighter = Math.max(la, lb);
|
||||
const darker = Math.min(la, lb);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/** `null` when either colour can't be resolved. */
|
||||
export function measureTextContrast(element: HTMLElement): number | null {
|
||||
const surface = effectiveBackgroundColor(element.parentElement ?? element);
|
||||
const color = parseCssColor(getComputedStyle(element).color);
|
||||
if (!color) return null;
|
||||
return contrastRatio(compositeOver(color, surface), surface);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
/** `<html>` attributes that can change what a colour token resolves to. */
|
||||
const WATCHED_ATTRIBUTES = ["data-theme", "data-icon-contrast", "style"];
|
||||
|
||||
/**
|
||||
* Read too early and a transitioning foreground pairs with an already-snapped
|
||||
* background. Clear of both the 150ms default and the 300ms call sites.
|
||||
*/
|
||||
const TRANSITION_SETTLE_MS = 500;
|
||||
|
||||
/* One observer for the page, not one per swatch. */
|
||||
const listeners = new Set<() => void>();
|
||||
let revision = 0;
|
||||
let observer: MutationObserver | null = null;
|
||||
let settleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function bump() {
|
||||
revision += 1;
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
/** Read now, then again once the transitions land. */
|
||||
function bumpAndSettle() {
|
||||
bump();
|
||||
if (settleTimer) clearTimeout(settleTimer);
|
||||
settleTimer = setTimeout(bump, TRANSITION_SETTLE_MS);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
|
||||
if (!observer) {
|
||||
observer = new MutationObserver(bumpAndSettle);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: WATCHED_ATTRIBUTES,
|
||||
});
|
||||
// In dev the stylesheet can land after hydration.
|
||||
requestAnimationFrame(bumpAndSettle);
|
||||
}
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
if (settleTimer) clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Ticks whenever the resolved palette could have moved. */
|
||||
export function useThemeRevision() {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => revision,
|
||||
() => 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme on `<html>`. Returned as a value, not inherited: the chart-2/3
|
||||
* override needs `data-theme` and `data-icon-contrast` on the same element.
|
||||
*/
|
||||
export function useDocumentTheme() {
|
||||
const revision = useThemeRevision();
|
||||
const [theme, setTheme] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
|
||||
setTheme(document.documentElement.getAttribute("data-theme") ?? undefined);
|
||||
}, [revision]);
|
||||
|
||||
return theme;
|
||||
}
|
||||
|
||||
export function useDocumentIconContrast() {
|
||||
const revision = useThemeRevision();
|
||||
const [isOn, setIsOn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
|
||||
setIsOn(document.documentElement.getAttribute("data-icon-contrast") === "true");
|
||||
}, [revision]);
|
||||
|
||||
return isOn;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CopyButton } from "~/components/primitives/CopyButton";
|
||||
import { CopyTextLink } from "~/components/primitives/CopyTextLink";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { TruncatedCopyableValue } from "~/components/primitives/TruncatedCopyableValue";
|
||||
import { Story, StoryGrid, StoryPage, StorySection } from "../storybook/StoryKit";
|
||||
|
||||
const RUN_ID = "run_cmsq1zzim1g2y2k8q1az3sfqf";
|
||||
|
||||
export default function Story_() {
|
||||
return (
|
||||
<StoryPage
|
||||
componentNames={[
|
||||
"CopyButton.tsx",
|
||||
"CopyableText.tsx",
|
||||
"TruncatedCopyableValue.tsx",
|
||||
"CopyTextLink.tsx",
|
||||
]}
|
||||
title="Copy & clipboard"
|
||||
description="The copy affordances: buttons, links, inline text and truncated IDs. Click any of them."
|
||||
>
|
||||
<StorySection title="CopyButton — button variant" description="Sizes with a label child.">
|
||||
<StoryGrid min="13rem">
|
||||
<Story label="extra-small">
|
||||
<CopyButton value={RUN_ID} variant="button" size="extra-small">
|
||||
Copy
|
||||
</CopyButton>
|
||||
</Story>
|
||||
<Story label="small">
|
||||
<CopyButton value={RUN_ID} variant="button" size="small">
|
||||
Copy
|
||||
</CopyButton>
|
||||
</Story>
|
||||
<Story label="medium">
|
||||
<CopyButton value={RUN_ID} variant="button" size="medium">
|
||||
Copy
|
||||
</CopyButton>
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="CopyButton — icon variant">
|
||||
<StoryGrid min="13rem">
|
||||
<Story label="extra-small">
|
||||
<CopyButton value={RUN_ID} variant="icon" size="extra-small" />
|
||||
</Story>
|
||||
<Story label="small">
|
||||
<CopyButton value={RUN_ID} variant="icon" size="small" />
|
||||
</Story>
|
||||
<Story label="medium">
|
||||
<CopyButton value={RUN_ID} variant="icon" size="medium" />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="CopyableText" description="Inline text that copies on click.">
|
||||
<StoryGrid min="16rem">
|
||||
<Story label="Default (icon on hover)">
|
||||
<CopyableText value={RUN_ID} className="font-mono text-xs" />
|
||||
</Story>
|
||||
<Story label="icon-right">
|
||||
<CopyableText value="hello-world" variant="icon-right" className="text-sm" />
|
||||
</Story>
|
||||
<Story label="Copy a different value">
|
||||
<CopyableText value="Display text" copyValue={RUN_ID} className="text-sm" />
|
||||
</Story>
|
||||
<Story label="text-below">
|
||||
<CopyableText value="hello-world" variant="text-below" className="text-sm" />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection
|
||||
title="TruncatedCopyableValue"
|
||||
description="Shows the tail of a long ID; the tooltip carries the whole value."
|
||||
>
|
||||
<StoryGrid min="16rem">
|
||||
<Story label="Default (last 8)">
|
||||
<TruncatedCopyableValue value={RUN_ID} />
|
||||
</Story>
|
||||
<Story label="length={12}">
|
||||
<TruncatedCopyableValue value={RUN_ID} length={12} />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="CopyTextLink">
|
||||
<StoryGrid min="16rem">
|
||||
<Story label="Default">
|
||||
<CopyTextLink value={RUN_ID} />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import { DateField } from "~/components/primitives/DateField";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { DateTimePicker } from "~/components/primitives/DateTimePicker";
|
||||
import { DurationPicker } from "~/components/primitives/DurationPicker";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Story, StoryGrid, StoryPage, StorySection } from "../storybook/StoryKit";
|
||||
|
||||
export default function Story() {
|
||||
export default function Story_() {
|
||||
return (
|
||||
<div className="m-8 space-y-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Header2>Size = small</Header2>
|
||||
<DateField label="From (UTC)" granularity="second" showNowButton showClearButton />
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
defaultValue={new Date()}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Header2>Size = medium</Header2>
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="medium"
|
||||
/>
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
defaultValue={new Date()}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="medium"
|
||||
/>
|
||||
</div>
|
||||
<StoryPage
|
||||
componentNames={["DateField.tsx", "DateTimePicker.tsx", "DurationPicker.tsx"]}
|
||||
title="Date fields"
|
||||
description="Segmented date entry, the combined date-time picker and the duration picker."
|
||||
>
|
||||
<StorySection title='DateField — variant="small" (default)'>
|
||||
<div className="flex flex-col gap-4">
|
||||
<DateField label="From (UTC)" granularity="second" showNowButton showClearButton />
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
defaultValue={new Date("2026-08-12T09:24:03.000Z")}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
/>
|
||||
</div>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="DateField — medium">
|
||||
<div className="flex flex-col gap-4">
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="medium"
|
||||
/>
|
||||
<DateField
|
||||
label="From (UTC)"
|
||||
defaultValue={new Date("2026-08-12T09:24:03.000Z")}
|
||||
granularity="second"
|
||||
showNowButton
|
||||
showClearButton
|
||||
variant="medium"
|
||||
/>
|
||||
</div>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="DateTimePicker">
|
||||
<StoryGrid min="20rem">
|
||||
<Story label="Controlled">
|
||||
<ControlledDateTimePicker />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="DurationPicker">
|
||||
<StoryGrid min="20rem">
|
||||
<Story label="small (default), starts at 90s">
|
||||
<ControlledDurationPicker />
|
||||
</Story>
|
||||
<Story label="medium">
|
||||
<ControlledDurationPicker variant="medium" />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlledDateTimePicker() {
|
||||
const [value, setValue] = useState<Date | undefined>(new Date("2026-08-12T09:24:00.000Z"));
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DateTimePicker
|
||||
label="Run at"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
showNowButton
|
||||
showClearButton
|
||||
/>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{value ? value.toISOString() : "No date selected"}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlledDurationPicker({ variant }: { variant?: "small" | "medium" }) {
|
||||
const [seconds, setSeconds] = useState(90);
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DurationPicker value={seconds} onChange={setSeconds} variant={variant} />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{seconds} seconds
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DateTime,
|
||||
DateTimeAccurate,
|
||||
DateTimeShort,
|
||||
RelativeDateTime,
|
||||
} from "~/components/primitives/DateTime";
|
||||
import { LiveCountdown, LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { Story, StoryGrid, StoryPage, StorySection } from "../storybook/StoryKit";
|
||||
|
||||
/* A fixed moment so the formatting variants are comparable at a glance. */
|
||||
const SAMPLE = new Date("2026-08-12T09:24:03.256Z");
|
||||
const SAMPLE_EARLIER = new Date("2026-08-12T09:23:41.881Z");
|
||||
const SAMPLE_PREVIOUS_DAY = new Date("2026-08-11T22:10:00.000Z");
|
||||
|
||||
export default function Story_() {
|
||||
// Anchored on mount so the live components have something to count against.
|
||||
const [mountedAt] = useState(() => new Date(Date.now() - 83_000));
|
||||
const [countdownEnd] = useState(() => new Date(Date.now() + 95_000));
|
||||
|
||||
return (
|
||||
<StoryPage
|
||||
title="Dates & timers"
|
||||
description="Every date formatting component, plus the live-updating timers used on run rows."
|
||||
>
|
||||
<StorySection title="DateTime" description="The standard formatter; hover for the tooltip.">
|
||||
<StoryGrid min="16rem">
|
||||
<Story label="Default">
|
||||
<DateTime date={SAMPLE} />
|
||||
</Story>
|
||||
<Story label="includeSeconds">
|
||||
<DateTime date={SAMPLE} includeSeconds />
|
||||
</Story>
|
||||
<Story label="includeTime={false}">
|
||||
<DateTime date={SAMPLE} includeTime={false} />
|
||||
</Story>
|
||||
<Story label="showTimezone">
|
||||
<DateTime date={SAMPLE} showTimezone />
|
||||
</Story>
|
||||
<Story label="hour12={false}">
|
||||
<DateTime date={SAMPLE} hour12={false} />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="Variants">
|
||||
<StoryGrid min="16rem">
|
||||
<Story label="DateTimeAccurate (ms)">
|
||||
<DateTimeAccurate date={SAMPLE} />
|
||||
</Story>
|
||||
<Story label="DateTimeShort">
|
||||
<DateTimeShort date={SAMPLE} />
|
||||
</Story>
|
||||
<Story label="RelativeDateTime">
|
||||
<RelativeDateTime date={SAMPLE_PREVIOUS_DAY} />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection
|
||||
title="Live timers"
|
||||
description="Update in place — used while a run is executing."
|
||||
>
|
||||
<StoryGrid min="16rem">
|
||||
<Story label="LiveTimer (running)">
|
||||
<LiveTimer startTime={mountedAt} />
|
||||
</Story>
|
||||
<Story label="LiveTimer (ended)">
|
||||
<LiveTimer startTime={SAMPLE_EARLIER} endTime={SAMPLE} />
|
||||
</Story>
|
||||
<Story label="LiveCountdown">
|
||||
<LiveCountdown endTime={countdownEnd} />
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
);
|
||||
}
|
||||
@@ -7,33 +7,86 @@ import {
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { DetailCell } from "~/components/primitives/DetailCell";
|
||||
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { StoryPage, StorySection } from "../storybook/StoryKit";
|
||||
|
||||
export default function Story() {
|
||||
const SAMPLE_DATE = new Date("2026-08-12T09:24:03.000Z");
|
||||
|
||||
export default function Story_() {
|
||||
return (
|
||||
<div className="flex max-w-xl flex-col items-start gap-y-8 p-8">
|
||||
<DetailCell
|
||||
leadingIcon={CodeBracketIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
label="Learn how to create your own API Integrations"
|
||||
variant="base"
|
||||
trailingIcon={ArrowTopRightOnSquareIcon}
|
||||
trailingIconClassName="text-tertiary group-hover:text-text-bright"
|
||||
/>
|
||||
<DetailCell
|
||||
leadingIcon={CodeBracketIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
label="Issue comment created"
|
||||
trailingIcon={CheckIcon}
|
||||
trailingIconClassName="text-green-500 group-hover:text-green-400"
|
||||
/>
|
||||
<DetailCell
|
||||
leadingIcon={ClockIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
label={<DateTime date={new Date()} />}
|
||||
description="Run #42 complete"
|
||||
trailingIcon={PlusIcon}
|
||||
trailingIconClassName="text-text-faint group-hover:text-text-bright"
|
||||
/>
|
||||
</div>
|
||||
<StoryPage
|
||||
componentNames={["DetailCell.tsx", "PropertyTable.tsx", "MiddleTruncate.tsx"]}
|
||||
title="Cells & key-value"
|
||||
description="Detail cells, property tables and middle truncation."
|
||||
>
|
||||
<StorySection title="DetailCell">
|
||||
<div className="flex max-w-xl flex-col items-start gap-y-4">
|
||||
<DetailCell
|
||||
leadingIcon={CodeBracketIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
label="Learn how to create your own API Integrations"
|
||||
variant="base"
|
||||
trailingIcon={ArrowTopRightOnSquareIcon}
|
||||
trailingIconClassName="text-tertiary group-hover:text-text-bright"
|
||||
/>
|
||||
<DetailCell
|
||||
leadingIcon={CodeBracketIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
label="Issue comment created"
|
||||
trailingIcon={CheckIcon}
|
||||
trailingIconClassName="text-green-500 group-hover:text-green-400"
|
||||
/>
|
||||
<DetailCell
|
||||
leadingIcon={ClockIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
label={<DateTime date={SAMPLE_DATE} />}
|
||||
description="Run #42 complete"
|
||||
trailingIcon={PlusIcon}
|
||||
trailingIconClassName="text-text-faint group-hover:text-text-bright"
|
||||
/>
|
||||
<DetailCell
|
||||
leadingIcon={CodeBracketIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
label='variant="small"'
|
||||
description="The tighter size"
|
||||
variant="small"
|
||||
trailingIcon={ArrowTopRightOnSquareIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
/>
|
||||
</div>
|
||||
</StorySection>
|
||||
|
||||
<StorySection title="PropertyTable" description="Label-over-value stacks for detail panes.">
|
||||
<div className="max-w-md rounded-sm border border-grid-dimmed p-4">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Run ID</Property.Label>
|
||||
<Property.Value>run_cmsq1zzim1g2y2k8q1az3sfqf</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>hello-world</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Started</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={SAMPLE_DATE} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
</StorySection>
|
||||
|
||||
<StorySection
|
||||
title="MiddleTruncate"
|
||||
description="Keeps both ends of a long value inside a fixed-width container."
|
||||
>
|
||||
<div className="flex w-64 flex-col gap-2 rounded-sm border border-grid-dimmed p-3 font-mono text-sm text-text-bright">
|
||||
<MiddleTruncate text="run_cmsq1zzim1g2y2k8q1az3sfqf" />
|
||||
<MiddleTruncate text="deployment_20260812_a1b2c3d4e5f6g7h8" />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertCancel,
|
||||
AlertContent,
|
||||
AlertDescription,
|
||||
AlertFooter,
|
||||
AlertHeader,
|
||||
AlertTitle,
|
||||
AlertTrigger,
|
||||
} from "~/components/primitives/Alert";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
DialogHeader,
|
||||
Dialog,
|
||||
@@ -6,22 +17,63 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Story, StoryGrid, StoryPage, StorySection } from "../storybook/StoryKit";
|
||||
|
||||
export default function Story() {
|
||||
export default function Story_() {
|
||||
return (
|
||||
<div className="grid h-full place-items-center p-20">
|
||||
<Dialog>
|
||||
<DialogTrigger>Open</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure absolutely sure?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete your account and remove your
|
||||
data from our servers.
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<StoryPage
|
||||
componentNames={["Dialog.tsx", "Alert.tsx"]}
|
||||
title="Dialog"
|
||||
description="Modal dialogs, plus the alert dialog for destructive confirmations."
|
||||
>
|
||||
<StorySection title="Dialog">
|
||||
<StoryGrid min="13rem">
|
||||
<Story label="Standard">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="secondary/small">Open dialog</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete your account and remove
|
||||
your data from our servers.
|
||||
</DialogDescription>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
|
||||
<StorySection
|
||||
title="Alert dialog"
|
||||
description="Blocks interaction until the user confirms or cancels."
|
||||
>
|
||||
<StoryGrid min="13rem">
|
||||
<Story label="Destructive confirm">
|
||||
<Alert>
|
||||
<AlertTrigger asChild>
|
||||
<Button variant="danger/small">Delete project</Button>
|
||||
</AlertTrigger>
|
||||
<AlertContent>
|
||||
<AlertHeader>
|
||||
<AlertTitle>Delete this project?</AlertTitle>
|
||||
<AlertDescription>
|
||||
All runs, schedules and environment variables will be permanently removed.
|
||||
</AlertDescription>
|
||||
</AlertHeader>
|
||||
<AlertFooter>
|
||||
<AlertCancel asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</AlertCancel>
|
||||
</AlertFooter>
|
||||
</AlertContent>
|
||||
</Alert>
|
||||
</Story>
|
||||
</StoryGrid>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { ComponentNames } from "../storybook/StoryKit";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="space-y-8 divide-y p-8">
|
||||
<div className="px-4 pt-4">
|
||||
<ComponentNames names={["EnvironmentLabel.tsx"]} />
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-y-8">
|
||||
<EnvironmentLabel environment={{ type: "PRODUCTION" }} />
|
||||
<EnvironmentLabel environment={{ type: "STAGING" }} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user