diff --git a/apps/webapp/app/assets/icons/AppearanceIcon.tsx b/apps/webapp/app/assets/icons/AppearanceIcon.tsx new file mode 100644 index 00000000000..f1fd1451fe7 --- /dev/null +++ b/apps/webapp/app/assets/icons/AppearanceIcon.tsx @@ -0,0 +1,23 @@ +/** Circle with one half filled — the theme/appearance setting. */ +export function AppearanceIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoonIcon.tsx b/apps/webapp/app/assets/icons/MoonIcon.tsx new file mode 100644 index 00000000000..f3e20e27f2c --- /dev/null +++ b/apps/webapp/app/assets/icons/MoonIcon.tsx @@ -0,0 +1,21 @@ +/** Crescent moon — the dark theme. */ +export function MoonIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SunIcon.tsx b/apps/webapp/app/assets/icons/SunIcon.tsx new file mode 100644 index 00000000000..b2ac93fd02a --- /dev/null +++ b/apps/webapp/app/assets/icons/SunIcon.tsx @@ -0,0 +1,34 @@ +/** + * Sun with rays — the light theme. The source artwork wrapped this in a mask and + * a clip path; both were no-ops at this viewBox, and dropping them keeps the + * markup free of ids that would collide when the icon renders more than once. + */ +export function SunIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx new file mode 100644 index 00000000000..51b8136e1dc --- /dev/null +++ b/apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx @@ -0,0 +1,23 @@ +/** Toggle switch, knob to the left. */ +export function ToggleSwitchIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/images/producthunt.png b/apps/webapp/app/assets/images/producthunt.png deleted file mode 100644 index e27a96f6976..00000000000 Binary files a/apps/webapp/app/assets/images/producthunt.png and /dev/null differ diff --git a/apps/webapp/app/components/ProductHuntBanner.tsx b/apps/webapp/app/components/ProductHuntBanner.tsx deleted file mode 100644 index abb5a146355..00000000000 --- a/apps/webapp/app/components/ProductHuntBanner.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import productHuntLogo from "../assets/images/producthunt.png"; -import { ArrowRightIcon } from "@heroicons/react/20/solid"; -import { Paragraph } from "./primitives/Paragraph"; -import { LinkButton } from "./primitives/Buttons"; - -export function ProductHuntBanner() { - return ( -
- - We're live on{" "} - - Product Hunt - - - Vote for us today only! - -
- ); -} diff --git a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx index e6362d4cb1d..125ae688dfb 100644 --- a/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx +++ b/apps/webapp/app/components/billing/BillingLimitConfigSection.tsx @@ -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({ @@ -338,10 +339,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{" "} - - terms - {" "} - for refund policy details. + terms for refund policy details. {cancelInProgressRuns ? ( <> In-progress runs will be cancelled when the limit is hit. ) : null} diff --git a/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx b/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx index 77aea48065a..04308b6263d 100644 --- a/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx +++ b/apps/webapp/app/components/dashboard-agent/RunDiagnosisCard.tsx @@ -7,6 +7,7 @@ import { useOptionalOrganization } from "~/hooks/useOrganizations"; import { useOptionalProject } from "~/hooks/useProject"; import { cn } from "~/utils/cn"; import { v3RunPath } from "~/utils/pathBuilder"; +import { textLinkClassName } from "~/components/primitives/TextLink"; // The "why did this run fail?" failure card — the first block in the dashboard // agent's view catalog. Rendered from a `diagnosis` block the agent emits via @@ -60,7 +61,7 @@ function RunLink({ runId, className }: { runId: string; className?: string }) { const to = useRunPath(runId); if (!to) return {runId}; return ( - + {runId} ); @@ -80,7 +81,7 @@ function EvidenceReference({ reference }: { reference: string }) { href={safeUrl} target="_blank" rel="noopener noreferrer" - className="font-mono text-xs text-indigo-400 underline hover:text-indigo-300" + className={cn(textLinkClassName(), "font-mono text-xs")} > {reference} diff --git a/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx new file mode 100644 index 00000000000..7890729359e --- /dev/null +++ b/apps/webapp/app/components/navigation/AppearanceMenuItem.tsx @@ -0,0 +1,82 @@ +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"; + +/** + * Theme switcher for the account popover: an "Appearance" submenu listing each theme, with a check + * against the current one. Picking a theme doesn't navigate, so the menu stays open and the new + * theme applies underneath it. Hidden entirely while the theme switcher feature flag is off, + * matching the account page. + */ +export function AppearanceMenuItem() { + const rootData = useTypedRouteLoaderData("root"); + const fetcher = useFetcher<{ success?: boolean }>(); + const savedTheme = rootData?.themePreference; + + // A failed write would otherwise leave the optimistic theme on screen, since + // the loader data never changes and so `useSystemThemeSync` never re-runs. + useEffect(() => { + if (fetcher.state !== "idle" || !fetcher.data || fetcher.data.success || !savedTheme) return; + applyThemePreference(savedTheme); + }, [fetcher.state, fetcher.data, savedTheme]); + + if (!rootData?.showThemeSwitcher) { + return null; + } + + // Move the check as soon as a theme is clicked; the write follows. + const pendingTheme = fetcher.formData?.get("theme"); + const theme = + typeof pendingTheme === "string" + ? normalizeThemePreference(pendingTheme) + : rootData.themePreference; + + const pickTheme = (value: ThemePreference) => { + // Applied here rather than waiting for the write to come back through the + // root loader: dismissing the popover unmounts this row, and an unmounted + // fetcher's revalidation is dropped, which left the theme untouched even + // though the preference had saved. + applyThemePreference(value); + fetcher.submit({ theme: value }, { method: "post", action: THEME_ACTION_PATH }); + }; + + return ( + // Much narrower than the standard submenu: these labels don't need the room. + +
+ {THEME_OPTIONS.map((option) => ( + pickTheme(option.value)} + /> + ))} +
+
+ +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/NotificationCard.tsx b/apps/webapp/app/components/navigation/NotificationCard.tsx index e9ad1e07e65..2274138b3e4 100644 --- a/apps/webapp/app/components/navigation/NotificationCard.tsx +++ b/apps/webapp/app/components/navigation/NotificationCard.tsx @@ -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, @@ -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?.(); diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index ebedc77fc6e..427a3551181 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -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, @@ -24,44 +20,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"; @@ -93,9 +72,6 @@ import { accountSecurityPath, personalAccessTokensPath, adminPath, - branchesPath, - concurrencyPath, - limitsPath, logoutPath, newOrganizationPath, newProjectPath, @@ -106,36 +82,20 @@ import { organizationSsoPath, organizationTeamPath, organizationVercelIntegrationPath, - queryPath, - regionsPath, - v3ApiKeysPath, - v3BatchesPath, v3BillingLimitsPath, v3BillingPath, v3PrivateConnectionsPath, - v3BulkActionsPath, - v3DashboardsLandingPath, - v3DeploymentsPath, v3EnvironmentPath, - v3EnvironmentVariablesPath, - v3ErrorsPath, - v3LogsPath, - v3ModelsPath, - v3ProjectAlertsPath, v3ProjectPath, v3ProjectSettingsGeneralPath, - v3ProjectSettingsIntegrationsPath, - v3PromptsPath, - v3QueuesPath, v3RunsPath, v3SessionsPath, v3UsagePath, - v3WaitpointTokensPath, } 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"; @@ -158,6 +118,7 @@ import { } from "../primitives/Tooltip"; import { ShortcutsAutoOpen } from "../Shortcuts"; import { type FavoritePage } from "~/services/dashboardPreferences.server"; +import { AppearanceMenuItem } from "./AppearanceMenuItem"; import { CustomizeSidebarDialog, type CustomizeSidebarSection, @@ -178,6 +139,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, @@ -195,31 +158,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 = { @@ -793,219 +731,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: , - }, - { - id: "models", - name: "Models", - icon: Box3DIcon, - activeIconColor: "text-models", - to: v3ModelsPath(organization, project, environment), - dataAction: "models", - badge: , - }, - ], - }); - } - - if (isAdmin || featureFlags.hasQueryAccess) { - 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: , - } satisfies SideMenuItemConfig, - ] - : []), - { - 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: ( - - ), - after: ( - - ), - }, - ], - }); - } - - 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: ( + + ), + after: ( + + ), + }, }); const sideMenuPrefs = user.dashboardPreferences.sideMenu; @@ -1898,6 +1649,7 @@ function AccountMenuItems({ leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON} className={SIDE_MENU_POPOVER_ITEM_LABEL} /> + (null); - - useEffect(() => { - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - }; - }, []); - - // Close the submenu on navigation (the parent popover closes too). - useEffect(() => { - 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 ( - setIsOpen(open)} open={isOpen}> -
- - - {title} - - - - {children} - -
-
- ); -} - function SwitchOrganizations({ organizations, organization, diff --git a/apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx b/apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx new file mode 100644 index 00000000000..ba8057613ea --- /dev/null +++ b/apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx @@ -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"; + +/** + * Hover-expandable submenu row for side-menu popovers (Account, Switch organization, Integrations, + * Appearance): 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. + */ +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(null); + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + // Close the submenu on navigation (the parent popover closes too). + useEffect(() => { + 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 ( + setIsOpen(open)} open={isOpen}> +
+ + + {title} + + + + {children} + +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/sideMenuSections.tsx b/apps/webapp/app/components/navigation/sideMenuSections.tsx new file mode 100644 index 00000000000..e625c3b7a74 --- /dev/null +++ b/apps/webapp/app/components/navigation/sideMenuSections.tsx @@ -0,0 +1,297 @@ +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 (everything except Tasks/Runs/Sessions), in DEFAULT order. +// Lives outside SideMenu so the profile page can build the same list for the "Customize sidebar" +// modal - it has no side menu of its own to read them from. + +export 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: , + }, + { + id: "models", + name: "Models", + icon: Box3DIcon, + activeIconColor: "text-models", + to: v3ModelsPath(organization, project, environment), + dataAction: "models", + badge: , + }, + ], + }); + } + + if (isAdmin || featureFlags.hasQueryAccess) { + 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: , + } satisfies SideMenuItemConfig, + ] + : []), + { + 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: dashboards?.action, + after: dashboards?.after, + }, + ], + }); + } + + 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; +} diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 78b201b90e0..c40225b144a 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -69,7 +69,10 @@ 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", + // On light, hover darkens off white. On the dark themes bg-secondary is + // charcoal-650, so hover steps one stop up the scale to charcoal-600 + // (surface-control) - background-raised is charcoal-700, i.e. 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", diff --git a/apps/webapp/app/components/primitives/LabelValueStack.tsx b/apps/webapp/app/components/primitives/LabelValueStack.tsx index 977ef6ee84c..15411ab3cbc 100644 --- a/apps/webapp/app/components/primitives/LabelValueStack.tsx +++ b/apps/webapp/app/components/primitives/LabelValueStack.tsx @@ -1,8 +1,8 @@ import { cn } from "~/utils/cn"; import { Paragraph } from "./Paragraph"; +import { TextLink } from "./TextLink"; import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; import { SimpleTooltip } from "./Tooltip"; -import { Link } from "@remix-run/react"; const variations = { primary: { @@ -69,9 +69,9 @@ function ValueButton({ value, href, variant = "secondary" }: ValueButtonStackPro if (!isExternalUrl) { return ( - + {value} - + ); } @@ -81,10 +81,14 @@ function ValueButton({ value, href, variant = "secondary" }: ValueButtonStackPro side="bottom" button={ - + {value} - - + } content={href} diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 31921ef9854..de294a63c19 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -29,8 +29,11 @@ const style = { "bg-transparent focus-custom hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none", }, secondary: { + // Hover matches the secondary button: darkens off white on light, and steps + // one stop up the charcoal scale on the dark themes, where background-raised + // (charcoal-700) sits below bg-secondary (charcoal-650) and read as dimming. 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", }, }; @@ -356,8 +359,10 @@ export function SelectTrigger({ {dropdownIcon === true ? ( ) : !dropdownIcon ? null : ( diff --git a/apps/webapp/app/components/primitives/SettingsLayout.tsx b/apps/webapp/app/components/primitives/SettingsLayout.tsx index dae340e9835..d9a7540c3e0 100644 --- a/apps/webapp/app/components/primitives/SettingsLayout.tsx +++ b/apps/webapp/app/components/primitives/SettingsLayout.tsx @@ -114,7 +114,7 @@ export function SettingsRowTitle({ ); } -/** Description/subtitle typography for a row. */ +/** Description/subtitle typography for a row - a step down from the title. */ export function SettingsRowDescription({ children, className, @@ -123,12 +123,16 @@ export function SettingsRowDescription({ className?: string; }) { return ( - + {children} ); } +/** Title-to-description spacing, kept in one place so every settings row and + * anything hand-rolling the pair reads the same. */ +export const SETTINGS_ROW_TITLE_GAP = "space-y-0.5"; + /** * A single settings row: title + description on the left, action on the right. * @@ -170,7 +174,7 @@ export function SettingsRow({ )} > {children ?? ( -
+
{title ? ( {title} @@ -229,7 +233,7 @@ export function SettingsAlertRow({ return ( -
+
{title} diff --git a/apps/webapp/app/components/primitives/Slider.tsx b/apps/webapp/app/components/primitives/Slider.tsx index e3ab6cdcd4e..a215a2939ea 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -1,8 +1,9 @@ import * as RadixSlider from "@radix-ui/react-slider"; -import type { ComponentProps } from "react"; +import { type ComponentProps, 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 */ @@ -12,10 +13,16 @@ const variants = { 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 + // The secondary-button hairline+shadow keeps the dot visible on the light + // track. Hover moves the handle off its resting tone in whichever direction + // reads as more prominent: brighter on the dark themes, dimmer on light. 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-charcoal-200 dark:border-transparent dark:bg-charcoal-300 dark:shadow-none dark:hover:bg-charcoal-200", + thumbSize: 18, + // Track-coloured line, notched off the track by borders in the colour of + // the page behind it (settings rows sit on background-dimmed). + 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", @@ -25,6 +32,9 @@ const variants = { 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)]", + thumbSize: 12, + mark: "bg-grid-bright border-background-dimmed", + markHover: "hover:bg-text-dimmed", }, }; @@ -34,6 +44,22 @@ export type SliderProps = ComponentProps & { LeadingIcon?: RenderIcon; TrailingIcon?: RenderIcon; variant: VariantName; + /** + * Opts into a small label above the thumb showing the formatted value, shown + * while the thumb is hovered or being dragged. It sits inside the thumb, so it + * tracks the handle exactly. Reads the controlled `value`, so pass one. + */ + valueTooltip?: (value: number) => string; + /** Values to tick on the track, e.g. the setting's default. */ + marks?: SliderMark[]; +}; + +export 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,9 +68,18 @@ export function Slider({ LeadingIcon, TrailingIcon, "aria-label": ariaLabel, + valueTooltip, + marks, ...props }: SliderProps) { const variation = variants[variant]; + // The pointer leaves the thumb while dragging, so hover alone can't keep the + // label up. + const [isDragging, setIsDragging] = useState(false); + const currentValue = props.value?.[0] ?? props.defaultValue?.[0] ?? 0; + const min = props.min ?? 0; + const max = props.max ?? 100; + return (
{LeadingIcon && } @@ -55,18 +90,93 @@ 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); + }} > + {marks?.map((mark) => { + const percent = ((mark.value - min) / (max - min)) * 100; + if (!Number.isFinite(percent) || percent < 0 || percent > 100) return null; + // Radix keeps the thumb inside the track by offsetting it against its + // own width, so a plain percentage would sit off the handle. Same + // formula as its `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)` }; + // Drawn after the track, so it sits on top of it - and before the + // thumb, so an overlapping handle keeps the hover and the click. + // box-content keeps the line 2px wide and hangs the borders outside + // it, so they read as a gap in the track rather than eating the line. + 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 ; + } + + return ( + event.stopPropagation()} + onClick={mark.onSelect} + /> + } + /> + ); + })} {/* The thumb is the role="slider" element, so the label lives here */} + > + {valueTooltip && ( + + {valueTooltip(currentValue)} + {/* Straddles the bottom edge, hiding the border it overlaps, so the + two outer sides read as an arrow pointing at the handle. */} + + + )} + {TrailingIcon && }
diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index e07187176a8..34365cade2d 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -52,6 +52,14 @@ const variations = { thumb: "size-3.5 data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0", text: "text-sm text-text-dimmed", }, + /* Like medium, minus the hover box: the toggle is the whole target, for rows + that already carry their own affordance. */ + "minimal/medium": { + container: "flex items-center gap-x-2 rounded-md focus-custom", + root: "h-4 w-8", + thumb: "size-3.5 data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0", + text: "text-sm text-text-dimmed", + }, }; type SwitchProps = React.ComponentPropsWithoutRef & { @@ -91,7 +99,7 @@ export const Switch = React.forwardRef diff --git a/apps/webapp/app/components/primitives/TextLink.tsx b/apps/webapp/app/components/primitives/TextLink.tsx index d0186268c0c..61c2d5ee5fc 100644 --- a/apps/webapp/app/components/primitives/TextLink.tsx +++ b/apps/webapp/app/components/primitives/TextLink.tsx @@ -6,11 +6,26 @@ import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKey import { ShortcutKey } from "./ShortcutKey"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip"; +const colors = { + primary: "text-indigo-500 transition hover:text-indigo-400", + secondary: "text-text-dimmed transition hover:text-text-bright", +} as const; + +/** + * A link's colour plus `inline-text-link`, the marker the "Underline links" + * preference targets (see tailwind.css) - without this component's layout. + * + * For links that can't be a `TextLink`: ones that must stay in the inline flow + * (markdown prose, where the component's inline-flex would stop them wrapping), + * and triggers that aren't anchors at all. + */ +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", + primary: cn(textLinkClassName("primary"), "inline-flex gap-0.5 items-center group"), + secondary: cn(textLinkClassName("secondary"), "inline-flex gap-0.5 items-center group"), } as const; type TextLinkProps = { @@ -24,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; export function TextLink({ @@ -37,6 +54,7 @@ export function TextLink({ shortcut, hideShortcutKey, tooltip, + reloadDocument, ...props }: TextLinkProps) { const innerRef = useRef(null); @@ -66,7 +84,13 @@ export function TextLink({ ); const linkElement = to ? ( - + {linkContent} ) : href ? ( diff --git a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx index e696f202559..1932087f3e2 100644 --- a/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx +++ b/apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx @@ -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. @@ -215,12 +216,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { } return ( @@ -270,12 +266,7 @@ export function renderPart(part: UIMessage["parts"][number], i: number) { } return ( diff --git a/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx b/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx index f7b09b6daf1..92a5c16be60 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIToolsInventory.tsx @@ -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 ?? []; @@ -48,7 +50,7 @@ function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolea
diff --git a/apps/webapp/app/components/themeOptions.ts b/apps/webapp/app/components/themeOptions.ts new file mode 100644 index 00000000000..53921f7d66e --- /dev/null +++ b/apps/webapp/app/components/themeOptions.ts @@ -0,0 +1,33 @@ +import { ComputerDesktopIcon, SwatchIcon } from "@heroicons/react/24/outline"; +import { type FunctionComponent } from "react"; +import { MoonIcon } from "~/assets/icons/MoonIcon"; +import { SunIcon } from "~/assets/icons/SunIcon"; +import { type ThemePreference } from "~/utils/themePreference"; + +export type ThemeOption = { + value: ThemePreference; + label: string; + icon: FunctionComponent<{ className?: string }>; +}; + +/** The themes on offer, in display order. Shared by every theme picker so the + * labels and icons can't drift apart. */ +export const THEME_OPTIONS: ThemeOption[] = [ + { value: "system", label: "System", icon: ComputerDesktopIcon }, + { value: "light", label: "Light", icon: SunIcon }, + { value: "dark", label: "Dark", icon: MoonIcon }, +]; + +/** Legacy theme, offered on the account page only. */ +export const CLASSIC_OPTION: ThemeOption = { + value: "classic", + label: "Classic", + icon: SwatchIcon, +}; + +/** Every theme, for the account page's full picker. */ +export const ALL_THEME_OPTIONS: ThemeOption[] = [...THEME_OPTIONS, CLASSIC_OPTION]; + +export const THEME_OPTIONS_BY_VALUE = Object.fromEntries( + ALL_THEME_OPTIONS.map((option) => [option.value, option]) +) as Record; diff --git a/apps/webapp/app/hooks/useSystemThemeSync.ts b/apps/webapp/app/hooks/useSystemThemeSync.ts index 6b2a678396f..36638094441 100644 --- a/apps/webapp/app/hooks/useSystemThemeSync.ts +++ b/apps/webapp/app/hooks/useSystemThemeSync.ts @@ -1,6 +1,23 @@ import { useEffect } from "react"; import { type ThemePreference } from "~/utils/themePreference"; +/** + * Puts a preference on now, resolving `system` against the OS once. Use + * this to apply a theme the moment it's picked: the preference round-trips + * through the server and comes back via the root loader, and anything that waits + * for that is at the mercy of whether the revalidation actually lands. + */ +export function applyThemePreference(preference: ThemePreference) { + const resolved = + preference === "system" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : preference; + document.documentElement.setAttribute("data-theme", resolved); + document.documentElement.setAttribute("data-theme-preference", preference); +} + /** * Keeps `data-theme` on in sync with the preference. For `system` it * follows the OS color scheme live; for pinned themes it writes the attribute @@ -13,7 +30,7 @@ import { type ThemePreference } from "~/utils/themePreference"; export function useSystemThemeSync(preference: ThemePreference) { useEffect(() => { if (preference !== "system") { - document.documentElement.setAttribute("data-theme", preference); + applyThemePreference(preference); return; } diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index 4e4fd1a90db..45bb11300a9 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -24,7 +24,9 @@ import { useSystemThemeSync } from "./hooks/useSystemThemeSync"; import { getImpersonationState } from "./services/impersonation.server"; import { getUser } from "./services/session.server"; import { + normalizeIconContrast, normalizeThemeContrast, + normalizeUnderlineLinks, normalizeThemePreference, type ThemePreference, } from "~/utils/themePreference"; @@ -94,6 +96,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const themeContrast = showThemeSwitcher ? normalizeThemeContrast(user?.dashboardPreferences.contrast) : 0; + // Icon and badge accents. Off by default, and forced off with the switcher + // hidden so logged-out and unflagged pages render the Classic set. + const iconContrast = showThemeSwitcher + ? normalizeIconContrast(user?.dashboardPreferences.iconContrast) + : false; + const underlineLinks = showThemeSwitcher + ? normalizeUnderlineLinks(user?.dashboardPreferences.underlineLinks) + : false; // 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. @@ -123,6 +133,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { kapa, timezone, showThemeSwitcher, + iconContrast, + underlineLinks, themePreference, themeContrast, // Consumed by ResizablePanel: the browser check must match between SSR @@ -176,6 +188,8 @@ export default function App() { kapa: _kapa, themePreference, themeContrast, + iconContrast, + underlineLinks, } = useTypedLoaderData(); usePostHog(posthogProjectKey, posthogUiHost); useSystemThemeSync(themePreference); @@ -192,6 +206,10 @@ export default function App() { suppressHydrationWarning data-theme={resolvedTheme} data-theme-preference={themePreference} + // Accent set for icons and badges; the `system:` variant keys off this + data-icon-contrast={iconContrast ? "true" : "false"} + // Underlines links carrying the inline-text-link marker class + data-underline-links={underlineLinks ? "true" : "false"} // Contrast overlay input for the System themes; Classic never reads it style={{ "--theme-contrast": themeContrast / 100 } as CSSProperties} > diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx index 73e1752715b..79556e734b0 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam/route.tsx @@ -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"; @@ -53,6 +53,7 @@ import { capitalizeWord } from "~/utils/string"; import { UserTag } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route"; import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas"; import { pageMeta } from "~/utils/pageTitle"; +import { TextLink } from "~/components/primitives/TextLink"; export const meta = pageMeta(({ params }) => [ params.deploymentParam ?? "Deployment", @@ -322,12 +323,12 @@ export default function Page() { Build Server - {deployment.externalBuildData.buildId} - + )} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx index 822050f19f9..bf88b35d334 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments/route.tsx @@ -80,6 +80,7 @@ import { useAutoRevalidate } from "~/hooks/useAutoRevalidate"; import { env } from "~/env.server"; import { DialogClose } from "@radix-ui/react-dialog"; import { pageMeta } from "~/utils/pageTitle"; +import { TextLink } from "~/components/primitives/TextLink"; export const meta = pageMeta("Deployments"); @@ -377,14 +378,18 @@ export default function Page() { {environmentGitHubBranch}
{" "} in - - {connectedGithubRepository.repository.fullName} - + {/* truncate needs a block-level child: the link itself is inline-flex */} + + {connectedGithubRepository.repository.fullName} + + {/* Table of contents */} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 7e37e9817c3..3bceb691e6c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -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() {
Active team members {roles.length > 0 ? ( - - View all role permissions → - + + View all role permissions + ) : null}
diff --git a/apps/webapp/app/routes/account._index/route.tsx b/apps/webapp/app/routes/account._index/route.tsx index 39dbf4b4e6e..fe4a201c51a 100644 --- a/apps/webapp/app/routes/account._index/route.tsx +++ b/apps/webapp/app/routes/account._index/route.tsx @@ -1,7 +1,6 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { conformZodMessage, parseWithZod } from "@conform-to/zod"; -import { ComputerDesktopIcon, MoonIcon, SunIcon, SwatchIcon } from "@heroicons/react/20/solid"; import { Form, useActionData, useFetcher, useLoaderData } from "@remix-run/react"; import { type ActionFunction, json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { z } from "zod"; @@ -12,6 +11,7 @@ import { PageContainer, } from "~/components/layout/AppLayout"; import { Button } from "~/components/primitives/Buttons"; +import { Dialog, DialogTrigger } from "~/components/primitives/Dialog"; import { Select, SelectItem } from "~/components/primitives/Select"; import { Slider } from "~/components/primitives/Slider"; import { FormError } from "~/components/primitives/FormError"; @@ -21,16 +21,39 @@ import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Switch } from "~/components/primitives/Switch"; import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { + SETTINGS_ROW_TITLE_GAP, + SettingsRowDescription, +} from "~/components/primitives/SettingsLayout"; +import { + CustomizeSidebarDialog, + type CustomizeSidebarSection, +} from "~/components/navigation/CustomizeSidebarDialog"; +import { + favoritePageIcon, + favoritePageIconClassName, + useFavorites, +} from "~/components/navigation/favoritePages"; +import { buildSideMenuSections } from "~/components/navigation/sideMenuSections"; +import { ALL_THEME_OPTIONS, THEME_OPTIONS_BY_VALUE } from "~/components/themeOptions"; import { prisma } from "~/db.server"; -import { useUser } from "~/hooks/useUser"; +import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server"; +import { useFeatureFlags } from "~/hooks/useFeatureFlags"; +import { applyThemePreference } from "~/hooks/useSystemThemeSync"; +import { useFeatures } from "~/hooks/useFeatures"; +import { useHasAdminAccess, useUser } from "~/hooks/useUser"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { updateUser } from "~/models/user.server"; import { updateContrastPreference, + updateIconContrastPreference, updateThemePreference, + updateUnderlineLinksPreference, } from "~/services/dashboardPreferences.server"; import { + normalizeIconContrast, normalizeThemeContrast, + normalizeUnderlineLinks, normalizeThemePreference, type ThemePreference, } from "~/utils/themePreference"; @@ -39,39 +62,25 @@ import { requireUser, requireUserId } from "~/services/session.server"; import { emailSchema, MAX_EMAIL_LENGTH } from "~/utils/emailValidation"; import { accountPath } from "~/utils/pathBuilder"; import { pageMeta } from "~/utils/pageTitle"; +import { cn } from "~/utils/cn"; -const THEME_LABELS: Record = { - classic: "Classic", - system: "System preference", - dark: "Dark", - light: "Light", -}; +export const meta = pageMeta("Your profile"); -function themeLabel(value: ThemePreference) { - return THEME_LABELS[value]; -} +/** Floor of the contrast slider. The stored value and the colours it drives are + * unchanged - this only stops the slider offering the bottom of the range. */ +const MIN_CONTRAST = 15; + +/** The contrast the slider ticks and labels as "Default". Note this is not the + * same as `DEFAULT_THEME_CONTRAST`, the value applied when none is saved. */ +const DEFAULT_CONTRAST_MARK = 30; function themeIcon(value: ThemePreference) { - switch (value) { - case "classic": - return ; - case "system": - return ; - case "dark": - // Moon glyph reads small at its natural size, so nudge it up inside a - // size-4 box to line up with the other icons. - return ( - - - - ); - case "light": - return ; - } + const Icon = THEME_OPTIONS_BY_VALUE[value].icon; + // shrink-0: without it the icon is the flex item that gives way to a long + // label, and "System"/"Classic" squash it to a sliver. + return ; } -export const meta = pageMeta("Your profile"); - function createSchema( constraints: { isEmailUnique?: (email: string) => Promise; @@ -113,7 +122,30 @@ export async function loader({ request }: LoaderFunctionArgs) { const user = await requireUser(request); const showThemeSwitcher = user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })); - return json({ showThemeSwitcher }); + + // The customize modal's section list is built from the side menu's items, which + // are keyed to a project and environment. Resolve the user's current one so the + // modal can open here rather than sending them into the app to find it. Null + // when they have no project yet, and the row hides itself. + let sidebarContext: { + organization: { slug: string }; + project: { slug: string }; + environment: { slug: string }; + } | null = null; + try { + const { organization, project, environment } = await new SelectBestEnvironmentPresenter().call({ + user, + }); + sidebarContext = { + organization: { slug: organization.slug }, + project: { slug: project.slug }, + environment: { slug: environment.slug }, + }; + } catch { + // No project to customize a sidebar for + } + + return json({ showThemeSwitcher, sidebarContext }); } export const action: ActionFunction = async ({ request }) => { @@ -145,6 +177,34 @@ export const action: ActionFunction = async ({ request }) => { return json({ success: true }); } + if (formData.get("action") === "update-icon-contrast") { + const user = await requireUser(request); + const showThemeSwitcher = + user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })); + if (!showThemeSwitcher) { + return json({ error: "Not available" }, { status: 404 }); + } + await updateIconContrastPreference({ + user, + iconContrast: formData.get("iconContrast") === "true", + }); + return json({ success: true }); + } + + if (formData.get("action") === "update-underline-links") { + const user = await requireUser(request); + const showThemeSwitcher = + user.admin || (await cachedFlag({ key: "hasThemeSwitcher", defaultValue: false })); + if (!showThemeSwitcher) { + return json({ error: "Not available" }, { status: 404 }); + } + await updateUnderlineLinksPreference({ + user, + underlineLinks: formData.get("underlineLinks") === "true", + }); + return json({ success: true }); + } + const formSchema = createSchema({ isEmailUnique: async (email) => { const existingUser = await prisma.user.findFirst({ @@ -189,12 +249,139 @@ export const action: ActionFunction = async ({ request }) => { } }; +/** + * Opens the side menu's own "Customize sidebar" modal from here, so the settings row doesn't send + * anyone into the app to find it. The section list is built from the same source the side menu + * renders from, keyed to the user's current project and environment. + * + * The fetcher lives here rather than in the dialog because closing the dialog unmounts it, which + * would abort a save mid-request - the same reason the side menu owns its copy. + */ +function CustomizeSidebarButton({ + context, +}: { + context: { + organization: { slug: string }; + project: { slug: string }; + environment: { slug: string }; + }; +}) { + const user = useUser(); + const isAdmin = useHasAdminAccess(); + const featureFlags = useFeatureFlags(); + const { isManagedCloud } = useFeatures(); + const favorites = useFavorites(); + const [isOpen, setIsOpen] = useState(false); + const [isConfirming, setIsConfirming] = useState(false); + const [error, setError] = useState(); + const fetcher = useFetcher<{ success: boolean }>(); + // The fetcher's data outlives a confirm, so only settle once THIS submission has been in flight. + const submitSeenRef = useRef(false); + + useEffect(() => { + if (!isConfirming) return; + if (fetcher.state !== "idle") { + submitSeenRef.current = true; + return; + } + if (!submitSeenRef.current) return; + setIsConfirming(false); + if (fetcher.data?.success) { + setIsOpen(false); + } else { + setError("Couldn't save your changes. Please try again."); + } + }, [isConfirming, fetcher.state, fetcher.data]); + + const sideMenuPrefs = user.dashboardPreferences.sideMenu; + const sections: CustomizeSidebarSection[] = [ + ...(favorites.length > 0 + ? [ + { + id: "favorites", + title: "Favorites", + items: favorites.map((favorite) => ({ + id: favorite.id, + name: favorite.label, + icon: favoritePageIcon(favorite.icon), + iconClassName: favoritePageIconClassName(favorite.icon), + isFavorite: true, + })), + }, + ] + : []), + ...buildSideMenuSections({ ...context, isAdmin, featureFlags, isManagedCloud }).map( + (section) => ({ + id: section.id, + title: section.title, + items: section.items.map((item) => ({ + id: item.id, + name: item.name, + icon: item.icon, + defaultHidden: item.defaultHidden, + })), + }) + ), + ]; + + return ( + { + setIsOpen(open); + if (!open) { + setIsConfirming(false); + setError(undefined); + } + }} + > + + + + {/* Mounted only while open so the modal re-seeds from current preferences each time */} + {isOpen && ( + { + setError(undefined); + setIsConfirming(true); + submitSeenRef.current = false; + fetcher.submit( + { customization: JSON.stringify(payload) }, + { method: "POST", action: "/resources/preferences/sidemenu" } + ); + }} + isConfirming={isConfirming} + confirmError={error} + /> + )} + + ); +} + export default function Page() { const user = useUser(); - const { showThemeSwitcher } = useLoaderData(); + const { showThemeSwitcher, sidebarContext } = useLoaderData(); const lastSubmission = useActionData(); const themeFetcher = useFetcher(); const contrastFetcher = useFetcher(); + const iconContrastFetcher = useFetcher(); + const pendingIconContrast = iconContrastFetcher.formData?.get("iconContrast"); + const iconContrast = + typeof pendingIconContrast === "string" + ? pendingIconContrast === "true" + : normalizeIconContrast(user.dashboardPreferences.iconContrast); + const underlineLinksFetcher = useFetcher(); + const pendingUnderlineLinks = underlineLinksFetcher.formData?.get("underlineLinks"); + const underlineLinks = + typeof pendingUnderlineLinks === "string" + ? pendingUnderlineLinks === "true" + : normalizeUnderlineLinks(user.dashboardPreferences.underlineLinks); const pendingTheme = themeFetcher.formData?.get("theme"); const pendingContrast = contrastFetcher.formData?.get("contrast"); const contrast = @@ -217,6 +404,18 @@ export default function Page() { } }, [contrastFetcher.state, contrast]); + // Dragging previews through the CSS var; releasing (or clicking the default + // mark) persists it. + const previewContrast = (value: number) => { + setContrastPreview(value); + document.documentElement.style.setProperty("--theme-contrast", String(value / 100)); + }; + const saveContrast = (value: number) => + contrastFetcher.submit( + { action: "update-contrast", contrast: String(value) }, + { method: "post" } + ); + const [form, { name, email, marketingEmails }] = useForm({ id: "account", // TODO: type this @@ -288,9 +487,8 @@ export default function Page() {
@@ -303,69 +501,166 @@ export default function Page() { {showThemeSwitcher && ( <> -
- Appearance +
+ Interface and theme
-
- - - aria-label="Interface theme" - value={theme} - setValue={(value) => - themeFetcher.submit( - { action: "update-theme", theme: value }, - { method: "post" } - ) - } - variant="secondary/small" - dropdownIcon - items={["classic", "system", "dark", "light"]} - text={(value) => ( - - {themeIcon(value)} - {themeLabel(value)} - - )} - className="w-44" - > - {(items) => - items.map((item) => ( - - {themeLabel(item)} - - )) - } - + {sidebarContext && ( +
+
+
+ + + Customize sidebar item visibility, order and rename favorites + +
+
+ +
+
+
+ )} +
+
+
+ + + Choose your interface color scheme + +
+
+ + aria-label="Interface theme" + value={theme} + setValue={(value) => { + // Applied here so the theme lands immediately rather than + // on the root loader's next pass (see applyThemePreference). + applyThemePreference(normalizeThemePreference(value)); + themeFetcher.submit( + { action: "update-theme", theme: value }, + { method: "post" } + ); + }} + variant="secondary/small" + dropdownIcon + items={ALL_THEME_OPTIONS.map((option) => option.value)} + text={(value) => ( + + {themeIcon(value)} + {THEME_OPTIONS_BY_VALUE[value].label} + + )} + // Hugs its label; the popover keeps the wider floor below so + // the options aren't cramped by the shortest one. + className="w-fit" + // The popover's 180px floor left a gap past the longest + // label; match the trigger instead. + popoverClassName="min-w-27" + > + {(items) => + items.map((item) => ( + + {THEME_OPTIONS_BY_VALUE[item].label} + + )) + } + +
+
{theme !== "classic" && ( -
- - { - // Live preview before the preference persists - const value = values[0] ?? 0; - setContrastPreview(value); - document.documentElement.style.setProperty( - "--theme-contrast", - String(value / 100) - ); - }} - onValueCommit={(values) => - contrastFetcher.submit( - { action: "update-contrast", contrast: String(values[0] ?? 0) }, - { method: "post" } - ) - } - /> +
+
+
+ + Adjust the interface contrast +
+
+ { + previewContrast(DEFAULT_CONTRAST_MARK); + saveContrast(DEFAULT_CONTRAST_MARK); + }, + }, + ]} + valueTooltip={(value) => + value === DEFAULT_CONTRAST_MARK ? "Default" : `${value}%` + } + value={[contrastPreview]} + onValueChange={(values) => previewContrast(values[0] ?? 0)} + onValueCommit={(values) => saveContrast(values[0] ?? 0)} + /> +
+
)} +
+
+
+ + + Increase the contrast of icons and badges + +
+
+ + iconContrastFetcher.submit( + { + action: "update-icon-contrast", + iconContrast: checked ? "true" : "false", + }, + { method: "post" } + ) + } + className="w-fit" + /> +
+
+
+
+
+
+ + + Always underline links in body text + +
+
+ + underlineLinksFetcher.submit( + { + action: "update-underline-links", + underlineLinks: checked ? "true" : "false", + }, + { method: "post" } + ) + } + /> +
+
+
)} diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx index cab7b8b41b2..14b12fa6369 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.select-plan.tsx @@ -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"; @@ -602,9 +602,7 @@ export function TierHobby({ - Request a BAA - + Request a BAA } /> @@ -756,9 +754,7 @@ export function TierPro({ - Request a BAA - + Request a BAA } /> @@ -816,9 +812,7 @@ export function TierEnterprise() { - Request a BAA - + Request a BAA } /> diff --git a/apps/webapp/app/routes/resources.preferences.theme.ts b/apps/webapp/app/routes/resources.preferences.theme.ts new file mode 100644 index 00000000000..901201c5497 --- /dev/null +++ b/apps/webapp/app/routes/resources.preferences.theme.ts @@ -0,0 +1,29 @@ +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: while the flag is off, everyone stays on the + // classic theme, so a preference must not be writable from the menu either. + 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(); + // Parsed strictly rather than normalized: an unknown value should fail loudly + // instead of silently resetting the user's theme to the default. + 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 }); +} diff --git a/apps/webapp/app/routes/storybook.underline-audit/route.tsx b/apps/webapp/app/routes/storybook.underline-audit/route.tsx new file mode 100644 index 00000000000..9ea66233232 --- /dev/null +++ b/apps/webapp/app/routes/storybook.underline-audit/route.tsx @@ -0,0 +1,530 @@ +import { ArrowRightIcon, ArrowTopRightOnSquareIcon, CogIcon } from "@heroicons/react/20/solid"; +import { Link } from "@remix-run/react"; +import { Header1, Header2 } from "~/components/primitives/Headers"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { TextLink } from "~/components/primitives/TextLink"; +import { cn } from "~/utils/cn"; + +/* + * TEMPORARY - delete once the underline decisions are made. + * + * Every link in the app that underlines itself instead of going through TextLink, + * so the "Underline links" preference doesn't reach it. Each is copied verbatim + * from its source (classes and markup) and dropped into enough of its + * surroundings to recognise. Labels match the audit table. + */ + +function Row({ + id, + file, + line, + kind, + note, + children, +}: { + id: string; + file: string; + line: string; + kind: "internal" | "external" | "hover-only" | "not-a-link"; + note?: string; + children: React.ReactNode; +}) { + const kindStyle = { + internal: "bg-blue-500/10 text-blue-500", + external: "bg-purple-500/10 text-purple-500", + "hover-only": "bg-amber-500/10 text-amber-500", + "not-a-link": "bg-charcoal-500/20 text-text-dimmed", + }[kind]; + + return ( +
+
{id}
+
+
+ + {file}:{line} + + {kind} +
+ {note ? {note} : null} +
{children}
+
+
+ ); +} + +function Section({ + title, + blurb, + children, +}: { + title: string; + blurb: string; + children: React.ReactNode; +}) { + return ( +
+
+ {title} +
+ + {blurb} + +
{children}
+
+ ); +} + +export default function Story() { + return ( +
+ Underline audit + + Temporary page. Links below underline themselves rather than using{" "} + TextLink, so the "Underline links" + preference leaves them as they are. Toggle the preference in Your profile to compare - only + the TextLink above should change. + + +
+ + Label + + + /orgs/acme/projects/my-project + + + + + + Label + + + https://trigger.dev + + + + + + +

+ We've shipped preview branches.{" "} + + Read the docs + {" "} + to get started. +

+
+ + +

+ The failure first appears in{" "} + + run_c8a91k2p0x + {" "} + and repeats on every retry. +

+
+ + + + + + + + + + + + + + + + When this limit is reached, queued runs will be held for 24 hours, then new triggers + will be rejected until you increase or remove the limit. See our{" "} + + terms + {" "} + for refund policy details. + + + + +
+ + We're live on{" "} + + Product Hunt + + + Vote for us today only! + +
+
+ + + + + + +
+ Match:{" "} + + claude-opus-4-20250514 + +
+
+ + +
+ + claude-sonnet-4-20250514 + +
+
+ + +
gpt-4o-mini-2024-07-18
+
+ + +
+ Deploying from + + triggerdotdev/trigger.dev + + +
+
+ + +
+ Build Server + + + bld_7ac91f2 + + +
+
+
+ +
+ +
+ +
+
+ + +
+ Active team members + + View all role permissions → + +
+
+ + + + +
+ +
+ + + Runs can be{" "} + + debounced + {" "} + before they queue. + + + + + + Includes{" "} + + 100,000 runs + {" "} + per month. + + + + +
+
+ NEXT_PUBLIC_API_URL +
+ + Values with a{" "} + + underline + {" "} + will be overwritten. + +
+
+ + +

+ https://cloud.trigger.dev/orgs/acme/projects/my-project +

+
+ + + + +
+ +
+ +
+ + Primary: an internal link and{" "} + an external one. + + + Secondary (no call sites today):{" "} + + a secondary link + + . + +
+
+
+
+ ); +} diff --git a/apps/webapp/app/routes/storybook/route.tsx b/apps/webapp/app/routes/storybook/route.tsx index e033f5d8ee2..a1bf6576229 100644 --- a/apps/webapp/app/routes/storybook/route.tsx +++ b/apps/webapp/app/routes/storybook/route.tsx @@ -155,6 +155,11 @@ const stories: Story[] = [ name: "Typography", slug: "typography", }, + { + // TEMPORARY - remove with storybook.underline-audit + name: "Underline audit", + slug: "underline-audit", + }, { name: "Unordered list", slug: "unordered-list", diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 772ba338ac6..87a73cc4015 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -178,6 +178,66 @@ export async function updateContrastPreference({ `; } +export async function updateIconContrastPreference({ + user, + iconContrast, +}: { + user: UserFromSession; + iconContrast: boolean; +}) { + if (user.isImpersonating) { + return; + } + + if ((user.dashboardPreferences.iconContrast ?? false) === iconContrast) { + return; + } + + // Narrow jsonb_set write: see updateThemePreference. + return prisma.$executeRaw` + UPDATE "User" + SET "dashboardPreferences" = jsonb_set( + COALESCE( + "dashboardPreferences", + '{"version":"1","projects":{}}'::jsonb + ), + '{iconContrast}', + to_jsonb(${iconContrast}::boolean) + ) + WHERE id = ${user.id} + `; +} + +export async function updateUnderlineLinksPreference({ + user, + underlineLinks, +}: { + user: UserFromSession; + underlineLinks: boolean; +}) { + if (user.isImpersonating) { + return; + } + + if ((user.dashboardPreferences.underlineLinks ?? false) === underlineLinks) { + return; + } + + // Narrow jsonb_set write: see updateThemePreference. + return prisma.$executeRaw` + UPDATE "User" + SET "dashboardPreferences" = jsonb_set( + COALESCE( + "dashboardPreferences", + '{"version":"1","projects":{}}'::jsonb + ), + '{underlineLinks}', + to_jsonb(${underlineLinks}::boolean) + ) + WHERE id = ${user.id} + `; +} + export async function clearCurrentProject({ user }: { user: UserFromSession }) { if (user.isImpersonating) { return; diff --git a/apps/webapp/app/tailwind.css b/apps/webapp/app/tailwind.css index 174a9a9ff5e..dad3c873e32 100644 --- a/apps/webapp/app/tailwind.css +++ b/apps/webapp/app/tailwind.css @@ -231,15 +231,18 @@ */ /* - System preference unified accents (dark+light); Classic keeps the original - set. One shared value per accent token, >=3:1 against both dark cards and - white. Accents whose Classic default already clears both modes (blue-500, - indigo-500, pink-500, purple-500, red-500, violet-500, fuchsia-500, - rose-600) are not repeated here. Text-sized tokens (text-link, callout - text) stay per-mode: no color reaches 4.5:1 on both #1a1b1f and #ffffff. - Dark derives everything else (monochrome surfaces etc.) from Classic. + High-contrast accents, opt-in via the "Icon contrast" preference + (data-icon-contrast on ), independent of the theme. One shared value + per accent token, >=3:1 against both dark cards and white. Accents whose + Classic default already clears both modes (blue-500, indigo-500, pink-500, + purple-500, red-500, violet-500, fuchsia-500, rose-600) are not repeated + here. Text-sized tokens (text-link, callout text) stay per-mode: no color + reaches 4.5:1 on both #1a1b1f and #ffffff. + + With the preference off, every theme keeps Classic's accents; the Light + theme darkens them for white further down, holding Classic's hues. */ -:is([data-theme="dark"], [data-theme="light"]) { +[data-icon-contrast="true"] { /* Status */ --color-success: var(--color-mint-600); --color-warning: var(--color-amber-600); @@ -279,25 +282,35 @@ --color-run-timed-out: #ed5f74; } -/* System themes drop decorative icon accents to monochrome; Classic keeps the - colored icons. side-menu-active-icon is set in SideMenuItem for the active - nav item; system-mono-icon marks section-header icons (e.g. the limits page). */ -:is([data-theme="dark"], [data-theme="light"]) :is(.side-menu-active-icon, .system-mono-icon) { +/* "Underline links" preference: underlines the inline links that carry the + marker class from the TextLink component, and only those - hand-rolled link + underlines and decorative ones (dashed tooltip terms, tab underlines) are + untouched either way. */ +[data-underline-links="true"] .inline-text-link { + text-decoration-line: underline; + text-underline-offset: 2px; +} + +/* Icon contrast drops decorative icon accents to monochrome; with it off, + the icons stay colored. side-menu-active-icon is set in SideMenuItem for the + active nav item; system-mono-icon marks section-header icons (e.g. the + limits page). */ +[data-icon-contrast="true"] :is(.side-menu-active-icon, .system-mono-icon) { color: var(--color-text-bright); } -/* System themes: status/env labels follow the surrounding text color, only the - icon keeps its tint. Classic colors both. Set in EnvironmentLabel and the - status combo components. */ -:is([data-theme="dark"], [data-theme="light"]) .system-mono-label { +/* Icon contrast: status/env labels follow the surrounding text color, only the + icon keeps its tint. With it off both are colored. Set in EnvironmentLabel + and the status combo components. */ +[data-icon-contrast="true"] .system-mono-label { color: inherit; } /* Tinted status chips respond to the contrast control: a ring in the chip's own text color fades in as contrast rises, so the soft tint keeps its - footprint but the chip gains definition. Transparent at contrast 0; - Classic never sets the variable. Marker set by the chip components. */ -:is([data-theme="dark"], [data-theme="light"]) .contrast-chip { + footprint but the chip gains definition. Transparent at contrast 0; only + set under icon contrast. Marker set by the chip components. */ +[data-icon-contrast="true"] .contrast-chip { box-shadow: inset 0 0 0 1px color-mix(in srgb, currentcolor calc(var(--theme-contrast, 0) * 70%), transparent); } @@ -424,9 +437,11 @@ Classic and Dark are both dark-mode themes, so the variant matches both. */ @custom-variant dark (&:where([data-theme="dark"], [data-theme="classic"], [data-theme="dark"] *, [data-theme="classic"] *)); -/* system: matches the System preference themes (Dark and Light) but never - Classic - for restyles that must leave Classic untouched. */ -@custom-variant system (&:where([data-theme="dark"], [data-theme="light"], [data-theme="dark"] *, [data-theme="light"] *)); +/* system: the high-contrast icon and badge treatment - solid status badges, + monochrome nav icons. Named for the System themes it shipped with, but it now + follows the "Icon contrast" preference in any theme; off means Classic's + tinted chips and colored icons. */ +@custom-variant system (&:where([data-icon-contrast="true"], [data-icon-contrast="true"] *)); /* light: the Light theme only - for values that are fine on every dark theme but illegible on white. Classic is never matched. */ @@ -789,8 +804,9 @@ --color-border-brighter: #b9bdc7; --color-border-brightest: #9ba1ad; - /* Status/env/icon accents live in the unified dark+light block above; - dev keeps its Classic pink-500, which passes on white too. */ + /* Accents come from one of two sets: the high-contrast block above when Icon + contrast is on, or the Classic-derived block below when it's off. Either + way dev keeps its Classic pink-500, which passes on white too. */ /* Staging is the one per-mode env color: warm orange has no clean unified mid-tone, so dark keeps Classic's bright orange-400 and light deepens it */ @@ -877,14 +893,58 @@ --color-editor-scrollbar-thumb-active: #aeb3be; } +/* + Light theme with Icon contrast off: Classic's accents were drawn for dark + cards and most sit under 3:1 on white (preview 1.57, warning 2.13, metrics + 2.22). This is the same palette read for a white page - each token keeps + Classic's hue and is only stepped down in lightness, so yellows stay yellow + and queues stay purple rather than moving to the blue the high-contrast set + uses. Every Classic accent that already clears white (dev, tasks, runs, + batches, logs, alerts...) is left alone. +*/ +[data-theme="light"]:not([data-icon-contrast="true"]) { + /* Status */ + --color-success: var(--color-mint-600); + --color-warning: var(--color-amber-600); + + /* Environments */ + --color-prod: var(--color-mint-600); + --color-preview: var(--color-yellow-700); + + /* Icons */ + --color-schedules: var(--color-yellow-700); + --color-previewBranches: var(--color-yellow-700); + --color-metrics: var(--color-green-600); + --color-regions: var(--color-green-600); + --color-aiMetrics: var(--color-green-600); + --color-bulkActions: var(--color-emerald-600); + --color-concurrency: var(--color-amber-600); + --color-errors: var(--color-amber-600); + --color-apiKeys: var(--color-amber-600); + + /* Queue charts keep Classic's purple, one step down for white */ + --color-queues-chart: var(--color-purple-600); + + /* Callout accents */ + --color-callout-docs: var(--color-blue-500); + --color-callout-pending: var(--color-blue-500); + --color-callout-pricing: var(--color-indigo-500); + + /* Amber run statuses, Classic's dark-to-light order kept */ + --color-run-waiting-for-deploy: var(--color-amber-700); + --color-run-pending-version: #c76508; + --color-run-paused: var(--color-amber-600); + --color-run-timed-out: #ed5f74; +} + /* Streamdown's muted surface has no semantic token (charcoal-775); theme it here */ [data-theme="light"] .streamdown-container { --muted: #eceef1; } -/* The timeline label shadow is a Classic legibility aid; the System themes - drop it entirely */ -:is([data-theme="dark"], [data-theme="light"]) .text-shadow-custom { +/* The timeline label shadow is a legibility aid for the Classic accents; the + high-contrast set doesn't need it */ +[data-icon-contrast="true"] .text-shadow-custom { text-shadow: none; } diff --git a/apps/webapp/app/utils/dashboardPreferences.ts b/apps/webapp/app/utils/dashboardPreferences.ts index ea0c6d8bfe2..45606749ff4 100644 --- a/apps/webapp/app/utils/dashboardPreferences.ts +++ b/apps/webapp/app/utils/dashboardPreferences.ts @@ -52,6 +52,10 @@ const DashboardPreferences = z.object({ theme: ThemePreference.optional().catch(undefined), /** Interface contrast for the System themes, 0-100. */ contrast: z.number().int().min(0).max(100).optional().catch(undefined), + /** Swaps the Classic icon and badge accents for the high-contrast set. */ + iconContrast: z.boolean().optional().catch(undefined), + /** Underlines inline links. */ + underlineLinks: z.boolean().optional().catch(undefined), currentProjectId: z.string().optional(), projects: z.record( z.string(), diff --git a/apps/webapp/app/utils/themePreference.ts b/apps/webapp/app/utils/themePreference.ts index 2b6408c2abc..f212336a3c6 100644 --- a/apps/webapp/app/utils/themePreference.ts +++ b/apps/webapp/app/utils/themePreference.ts @@ -16,6 +16,17 @@ export function normalizeThemePreference(value: unknown): ThemePreference { /** The default dark theme ships with a slight contrast bump. */ export const DEFAULT_THEME_CONTRAST = 50; +/** Icon and badge contrast: on swaps the Classic accents for the high-contrast + * set (solid badges, monochrome nav icons). Off is the default. */ +export function normalizeIconContrast(value: unknown): boolean { + return value === true; +} + +/** Underlines inline links (the TextLink component). Off is the default. */ +export function normalizeUnderlineLinks(value: unknown): boolean { + return value === true; +} + /** Interface contrast for the System themes, 0 to 100. Missing or invalid * values fall back to the default bump. */ export function normalizeThemeContrast(value: unknown): number {