From 4c5237ca4af946a4385eafdf2df0f1cf66027ed2 Mon Sep 17 00:00:00 2001 From: James Ritchie <55853254+nicktrn@users.noreply.github.com> Date: Fri, 21 Aug 2026 19:27:52 +0100 Subject: [PATCH] feat(webapp): themes refinement, new black & white themes, 2 accessibility toggles (#4547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- [Open workspace in Conductor](https://app.conductor.build/workspace/fee50611-7623-4422-bada-ed1cba317ed1) --------- Co-authored-by: Claude Opus 5 (1M context) --- .../appearance-themes-and-options.md | 6 + .../app/assets/icons/CircleFilledIcon.tsx | 16 + .../app/assets/icons/CircleOutlineIcon.tsx | 16 + .../app/assets/icons/EditPencilIcon.tsx | 22 + apps/webapp/app/assets/icons/MonitorIcon.tsx | 21 + apps/webapp/app/assets/icons/MoonIcon.tsx | 21 + apps/webapp/app/assets/icons/SunIcon.tsx | 29 + .../app/assets/icons/ToggleSwitchIcon.tsx | 23 + apps/webapp/app/assets/images/producthunt.png | Bin 5677 -> 0 bytes .../billing/BillingLimitConfigSection.tsx | 6 +- .../app/components/billing/FreePlanUsage.tsx | 3 +- .../dashboard-agent/AgentUpgradeGate.tsx | 6 +- .../DashboardAgentComposer.tsx | 6 +- .../dashboard-agent/InvestigationCard.tsx | 4 +- .../dashboard-agent/RunDiagnosisCard.tsx | 5 +- .../dashboard-agent/agent-badges.tsx | 14 +- .../components/errors/ErrorStatusBadge.tsx | 4 +- .../app/components/logs/LogsLevelFilter.tsx | 44 +- .../navigation/AppearanceMenuItem.tsx | 72 + .../navigation/EnvironmentSelector.tsx | 4 +- .../navigation/NotificationCard.tsx | 5 +- .../app/components/navigation/SideMenu.tsx | 393 +--- .../navigation/SideMenuPopoverSubMenu.tsx | 88 + .../navigation/sideMenuSections.tsx | 300 +++ .../app/components/primitives/Avatar.tsx | 27 +- .../app/components/primitives/Badge.tsx | 11 +- .../app/components/primitives/Buttons.tsx | 20 +- .../app/components/primitives/Calendar.tsx | 4 +- .../app/components/primitives/CopyButton.tsx | 3 + .../app/components/primitives/PulsingDot.tsx | 6 +- .../primitives/SegmentedControl.tsx | 2 +- .../app/components/primitives/Select.tsx | 6 +- .../components/primitives/SettingsLayout.tsx | 17 +- .../app/components/primitives/Slider.tsx | 132 +- .../app/components/primitives/Spinner.tsx | 19 +- .../app/components/primitives/Switch.tsx | 31 +- .../webapp/app/components/primitives/Tabs.tsx | 31 +- .../app/components/primitives/TextLink.tsx | 38 +- .../components/primitives/charts/Chart.tsx | 6 +- .../primitives/charts/ChartCard.tsx | 81 +- .../primitives/charts/ChartLegendCompound.tsx | 5 +- .../primitives/charts/ChartLine.tsx | 6 +- .../runs/v3/agent/AgentMessageView.tsx | 15 +- .../runs/v3/ai/AIToolsInventory.tsx | 4 +- .../schedules/PurchaseSchedulesModal.tsx | 6 +- apps/webapp/app/components/themeOptions.ts | 54 + apps/webapp/app/hooks/useSystemThemeSync.ts | 93 +- apps/webapp/app/hooks/useThemeMode.ts | 9 +- apps/webapp/app/models/user.server.ts | 34 + .../OrganizationsPresenter.server.ts | 21 +- .../v3/AgentListPresenter.server.ts | 20 +- .../presenters/v3/TaskListPresenter.server.ts | 22 +- .../v3/UnifiedTaskListPresenter.server.ts | 38 +- apps/webapp/app/root.tsx | 65 +- .../route.tsx | 5 +- .../route.tsx | 6 +- .../route.tsx | 3 +- .../route.tsx | 9 +- .../route.tsx | 13 +- .../TRQLGuideContent.tsx | 16 +- .../route.tsx | 12 +- .../route.tsx | 5 +- .../route.tsx | 11 +- .../route.tsx | 14 +- .../route.tsx | 16 +- .../app/routes/account._index/route.tsx | 1248 +++++++++--- .../webapp/app/routes/admin.feature-flags.tsx | 4 +- apps/webapp/app/routes/login._index/route.tsx | 6 +- apps/webapp/app/routes/login.mfa/route.tsx | 12 +- apps/webapp/app/routes/login.sso/route.tsx | 2 +- .../MfaDisableDialog.tsx | 6 +- .../route.tsx | 8 +- ...ces.orgs.$organizationSlug.select-plan.tsx | 21 +- .../app/routes/resources.preferences.theme.ts | 27 + .../app/routes/storybook.accordion/route.tsx | 69 + .../storybook.agent-investigation/route.tsx | 4 +- .../routes/storybook.agent-report/route.tsx | 2 +- .../app/routes/storybook.agent-ui/gallery.tsx | 28 +- .../app/routes/storybook.agent-ui/route.tsx | 12 +- .../storybook.agent-view-blocks/route.tsx | 8 +- .../routes/storybook.agent-watch/route.tsx | 8 +- .../app/routes/storybook.ai-agent/route.tsx | 4 + .../routes/storybook.animated-panel/route.tsx | 4 + .../app/routes/storybook.avatar/route.tsx | 4 + .../app/routes/storybook.badges/route.tsx | 48 +- .../app/routes/storybook.buttons/route.tsx | 575 ++---- .../app/routes/storybook.callout/route.tsx | 4 + .../app/routes/storybook.charts/route.tsx | 4 + .../app/routes/storybook.checkboxes/route.tsx | 19 + .../storybook.clipboard-field/route.tsx | 4 + .../app/routes/storybook.code-block/route.tsx | 4 + .../app/routes/storybook.colors/contrast.ts | 104 + .../app/routes/storybook.colors/route.tsx | 1671 +++++++++++++++++ .../storybook.colors/useThemeRevision.ts | 89 + .../app/routes/storybook.copy/route.tsx | 95 + .../routes/storybook.date-fields/route.tsx | 124 +- .../routes/storybook.dates-timers/route.tsx | 78 + .../routes/storybook.detail-cell/route.tsx | 105 +- .../app/routes/storybook.dialog/route.tsx | 82 +- .../storybook.environment-label/route.tsx | 4 + .../app/routes/storybook.filter/route.tsx | 4 + .../storybook.free-plan-usage/route.tsx | 4 + .../app/routes/storybook.icons/route.tsx | 4 + .../app/routes/storybook.indicators/route.tsx | 66 + .../app/routes/storybook.info-panel/route.tsx | 11 + .../routes/storybook.inline-code/route.tsx | 40 +- .../routes/storybook.input-fields/route.tsx | 82 +- .../app/routes/storybook.layout/route.tsx | 4 + .../storybook.loading-bar-divider/route.tsx | 4 + .../app/routes/storybook.log-levels/route.tsx | 50 + .../routes/storybook.page-header/route.tsx | 4 + .../app/routes/storybook.pagination/route.tsx | 66 + .../app/routes/storybook.popover/route.tsx | 158 +- .../storybook.pricing-callout/route.tsx | 4 + .../routes/storybook.radio-group/route.tsx | 4 + .../app/routes/storybook.resizable/route.tsx | 4 + .../storybook.run-and-span-timeline/route.tsx | 4 + .../routes/storybook.run-statuses/route.tsx | 138 ++ .../routes/storybook.search-fields/route.tsx | 4 + .../storybook.segmented-control/route.tsx | 155 +- .../app/routes/storybook.select/route.tsx | 235 ++- .../routes/storybook.settings-rows/route.tsx | 117 ++ .../app/routes/storybook.sheet/route.tsx | 56 + .../app/routes/storybook.shortcuts/route.tsx | 15 + .../routes/storybook.simple-form/route.tsx | 6 + .../app/routes/storybook.slider/route.tsx | 103 + .../app/routes/storybook.spinner/route.tsx | 97 +- .../app/routes/storybook.stepper/route.tsx | 4 + .../app/routes/storybook.streamdown/route.tsx | 4 + .../app/routes/storybook.switch/route.tsx | 79 +- .../app/routes/storybook.table/route.tsx | 148 +- .../app/routes/storybook.tabs/route.tsx | 38 +- .../app/routes/storybook.textarea/route.tsx | 4 + .../app/routes/storybook.timeline/route.tsx | 4 + .../app/routes/storybook.toast/route.tsx | 4 + .../app/routes/storybook.tooltip/route.tsx | 129 +- .../app/routes/storybook.tree-view/route.tsx | 4 + .../routes/storybook.tsql-editor/route.tsx | 4 + .../app/routes/storybook.typography/route.tsx | 22 + .../routes/storybook.unordered-list/route.tsx | 4 + .../app/routes/storybook.usage/route.tsx | 57 + apps/webapp/app/routes/storybook/StoryKit.tsx | 132 ++ apps/webapp/app/routes/storybook/route.tsx | 502 ++--- .../services/dashboardPreferences.server.ts | 100 +- .../profileUpdateRateLimiter.server.ts | 27 + .../app/services/ssoManagedIdentity.server.ts | 83 + apps/webapp/app/tailwind.css | 362 ++-- apps/webapp/app/utils/backstopPromise.ts | 9 + apps/webapp/app/utils/dashboardPreferences.ts | 11 +- apps/webapp/app/utils/logUtils.ts | 15 +- apps/webapp/app/utils/themePreference.ts | 46 +- apps/webapp/app/v3/featureFlags.server.ts | 33 + apps/webapp/test/ssoManagedIdentity.test.ts | 86 + apps/webapp/test/themePreference.test.ts | 12 +- 154 files changed, 7786 insertions(+), 2107 deletions(-) create mode 100644 .server-changes/appearance-themes-and-options.md create mode 100644 apps/webapp/app/assets/icons/CircleFilledIcon.tsx create mode 100644 apps/webapp/app/assets/icons/CircleOutlineIcon.tsx create mode 100644 apps/webapp/app/assets/icons/EditPencilIcon.tsx create mode 100644 apps/webapp/app/assets/icons/MonitorIcon.tsx create mode 100644 apps/webapp/app/assets/icons/MoonIcon.tsx create mode 100644 apps/webapp/app/assets/icons/SunIcon.tsx create mode 100644 apps/webapp/app/assets/icons/ToggleSwitchIcon.tsx delete mode 100644 apps/webapp/app/assets/images/producthunt.png create mode 100644 apps/webapp/app/components/navigation/AppearanceMenuItem.tsx create mode 100644 apps/webapp/app/components/navigation/SideMenuPopoverSubMenu.tsx create mode 100644 apps/webapp/app/components/navigation/sideMenuSections.tsx create mode 100644 apps/webapp/app/components/themeOptions.ts create mode 100644 apps/webapp/app/routes/resources.preferences.theme.ts create mode 100644 apps/webapp/app/routes/storybook.accordion/route.tsx create mode 100644 apps/webapp/app/routes/storybook.colors/contrast.ts create mode 100644 apps/webapp/app/routes/storybook.colors/route.tsx create mode 100644 apps/webapp/app/routes/storybook.colors/useThemeRevision.ts create mode 100644 apps/webapp/app/routes/storybook.copy/route.tsx create mode 100644 apps/webapp/app/routes/storybook.dates-timers/route.tsx create mode 100644 apps/webapp/app/routes/storybook.indicators/route.tsx create mode 100644 apps/webapp/app/routes/storybook.log-levels/route.tsx create mode 100644 apps/webapp/app/routes/storybook.pagination/route.tsx create mode 100644 apps/webapp/app/routes/storybook.run-statuses/route.tsx create mode 100644 apps/webapp/app/routes/storybook.settings-rows/route.tsx create mode 100644 apps/webapp/app/routes/storybook.sheet/route.tsx create mode 100644 apps/webapp/app/routes/storybook.slider/route.tsx create mode 100644 apps/webapp/app/routes/storybook.usage/route.tsx create mode 100644 apps/webapp/app/routes/storybook/StoryKit.tsx create mode 100644 apps/webapp/app/services/profileUpdateRateLimiter.server.ts create mode 100644 apps/webapp/app/services/ssoManagedIdentity.server.ts create mode 100644 apps/webapp/app/utils/backstopPromise.ts create mode 100644 apps/webapp/test/ssoManagedIdentity.test.ts diff --git a/.server-changes/appearance-themes-and-options.md b/.server-changes/appearance-themes-and-options.md new file mode 100644 index 000000000..9f4637763 --- /dev/null +++ b/.server-changes/appearance-themes-and-options.md @@ -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. diff --git a/apps/webapp/app/assets/icons/CircleFilledIcon.tsx b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx new file mode 100644 index 000000000..a6d10485b --- /dev/null +++ b/apps/webapp/app/assets/icons/CircleFilledIcon.tsx @@ -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 ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx new file mode 100644 index 000000000..e60de2190 --- /dev/null +++ b/apps/webapp/app/assets/icons/CircleOutlineIcon.tsx @@ -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 ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EditPencilIcon.tsx b/apps/webapp/app/assets/icons/EditPencilIcon.tsx new file mode 100644 index 000000000..18728c03e --- /dev/null +++ b/apps/webapp/app/assets/icons/EditPencilIcon.tsx @@ -0,0 +1,22 @@ +/** Pencil over a couple of text lines — editing a value in place. */ +export function EditPencilIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MonitorIcon.tsx b/apps/webapp/app/assets/icons/MonitorIcon.tsx new file mode 100644 index 000000000..09aae2798 --- /dev/null +++ b/apps/webapp/app/assets/icons/MonitorIcon.tsx @@ -0,0 +1,21 @@ +/** Monitor on a stand — the System theme, which follows the OS appearance. */ +export function MonitorIcon({ 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 000000000..f3e20e27f --- /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 000000000..1601722dd --- /dev/null +++ b/apps/webapp/app/assets/icons/SunIcon.tsx @@ -0,0 +1,29 @@ +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 000000000..51b8136e1 --- /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 e27a96f697651eeb202f8a0372f718c198b7dbb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5677 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 I?~xP#0(| - 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/billing/FreePlanUsage.tsx b/apps/webapp/app/components/billing/FreePlanUsage.tsx index d1f071656..a73e3a8a4 100644 --- a/apps/webapp/app/components/billing/FreePlanUsage.tsx +++ b/apps/webapp/app/components/billing/FreePlanUsage.tsx @@ -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 Free Plan - + Upgrade diff --git a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx index 70a5da7b8..82558fbda 100644 --- a/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx +++ b/apps/webapp/app/components/dashboard-agent/AgentUpgradeGate.tsx @@ -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 · - + Upgrade diff --git a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx index cc1370c12..3ecfba0fe 100644 --- a/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx +++ b/apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx @@ -54,11 +54,13 @@ export function DashboardAgentComposer({ const sendButton = isStreaming ? ( @@ -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 e8a6a99f2..15c940b5f 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, @@ -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: , - }, - { - id: "models", - name: "Models", - icon: Box3DIcon, - activeIconColor: "text-models", - to: v3ModelsPath(organization, project, environment), - dataAction: "models", - badge: , - }, - ], - }); - } - - 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: , - } 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: ( - - ), - 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; @@ -1930,6 +1677,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(() => { - // 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 ( - 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 000000000..65a3719fb --- /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"; + +/** + * 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(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 ( + 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 000000000..87d5da611 --- /dev/null +++ b/apps/webapp/app/components/navigation/sideMenuSections.tsx @@ -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: , + }, + { + id: "models", + name: "Models", + icon: Box3DIcon, + activeIconColor: "text-models", + to: v3ModelsPath(organization, project, environment), + dataAction: "models", + badge: , + }, + ], + }); + } + + 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: , + } 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; +} diff --git a/apps/webapp/app/components/primitives/Avatar.tsx b/apps/webapp/app/components/primitives/Avatar.tsx index 52b2af5d9..e044e7fae 100644 --- a/apps/webapp/app/components/primitives/Avatar.tsx +++ b/apps/webapp/app/components/primitives/Avatar.tsx @@ -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({ diff --git a/apps/webapp/app/components/primitives/Badge.tsx b/apps/webapp/app/components/primitives/Badge.tsx index e81c055f5..6178c21c6 100644 --- a/apps/webapp/app/components/primitives/Badge.tsx +++ b/apps/webapp/app/components/primitives/Badge.tsx @@ -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 & { diff --git a/apps/webapp/app/components/primitives/Buttons.tsx b/apps/webapp/app/components/primitives/Buttons.tsx index 7446eccf0..03d3c5538 100644 --- a/apps/webapp/app/components/primitives/Buttons.tsx +++ b/apps/webapp/app/components/primitives/Buttons.tsx @@ -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" ? ( - - {text} - + {text} ) : ( text ))} @@ -345,8 +348,11 @@ export function ButtonContent(props: ButtonContentPropsType) { renderShortcutKey()} {showSpinner && ( - - + // Inherits the variant's colour so the spinner tracks its button. + + )} diff --git a/apps/webapp/app/components/primitives/Calendar.tsx b/apps/webapp/app/components/primitives/Calendar.tsx index 22ddc7d41..7d96a3705 100644 --- a/apps/webapp/app/components/primitives/Calendar.tsx +++ b/apps/webapp/app/components/primitives/Calendar.tsx @@ -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", diff --git a/apps/webapp/app/components/primitives/CopyButton.tsx b/apps/webapp/app/components/primitives/CopyButton.tsx index 88a663dfe..566609a85 100644 --- a/apps/webapp/app/components/primitives/CopyButton.tsx +++ b/apps/webapp/app/components/primitives/CopyButton.tsx @@ -104,6 +104,9 @@ export function CopyButton({ return ( ; 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} diff --git a/apps/webapp/app/components/primitives/PulsingDot.tsx b/apps/webapp/app/components/primitives/PulsingDot.tsx index 97c2a1093..7a59df7bc 100644 --- a/apps/webapp/app/components/primitives/PulsingDot.tsx +++ b/apps/webapp/app/components/primitives/PulsingDot.tsx @@ -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 ( - + - + ); } diff --git a/apps/webapp/app/components/primitives/SegmentedControl.tsx b/apps/webapp/app/components/primitives/SegmentedControl.tsx index 6d88d2aff..eca5438c3 100644 --- a/apps/webapp/app/components/primitives/SegmentedControl.tsx +++ b/apps/webapp/app/components/primitives/SegmentedControl.tsx @@ -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: diff --git a/apps/webapp/app/components/primitives/Select.tsx b/apps/webapp/app/components/primitives/Select.tsx index 04b7467de..466dcc5c8 100644 --- a/apps/webapp/app/components/primitives/Select.tsx +++ b/apps/webapp/app/components/primitives/Select.tsx @@ -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({ {dropdownIcon === true ? ( ) : !dropdownIcon ? null : ( diff --git a/apps/webapp/app/components/primitives/SettingsLayout.tsx b/apps/webapp/app/components/primitives/SettingsLayout.tsx index 1cb5b651f..784174c07 100644 --- a/apps/webapp/app/components/primitives/SettingsLayout.tsx +++ b/apps/webapp/app/components/primitives/SettingsLayout.tsx @@ -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 ( -
{children}
+
{children}
); } @@ -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 ?? ( -
+
{title ? ( {title} @@ -227,7 +236,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 e3ab6cdcd..c165aa97c 100644 --- a/apps/webapp/app/components/primitives/Slider.tsx +++ b/apps/webapp/app/components/primitives/Slider.tsx @@ -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 & { 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 ( -
+
setIsDragging(false)} + > {LeadingIcon && } { + props.onPointerDown?.(event); + setIsDragging(true); + }} + onPointerUp={(event) => { + props.onPointerUp?.(event); + setIsDragging(false); + }} + onPointerCancel={(event) => { + props.onPointerCancel?.(event); + setIsDragging(false); + }} > - {/* 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 ; + } + + return ( + event.stopPropagation()} + onClick={mark.onSelect} + /> + } + /> + ); + })} + > + {valueTooltip && ( + + {valueTooltip(currentValue)} + + + )} + {TrailingIcon && }
diff --git a/apps/webapp/app/components/primitives/Spinner.tsx b/apps/webapp/app/components/primitives/Spinner.tsx index 1fccb1333..0e86e3f55 100644 --- a/apps/webapp/app/components/primitives/Spinner.tsx +++ b/apps/webapp/app/components/primitives/Spinner.tsx @@ -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", }, }; diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index eed7799fe..a78623ee0 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -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
diff --git a/apps/webapp/app/components/primitives/Tabs.tsx b/apps/webapp/app/components/primitives/Tabs.tsx index d7762f338..aa7631f46 100644 --- a/apps/webapp/app/components/primitives/Tabs.tsx +++ b/apps/webapp/app/components/primitives/Tabs.tsx @@ -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 ( {({ 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 ( - + {({ isActive, isPending }) => { const active = isActive || isPending; return ( @@ -142,7 +142,9 @@ function TabLink({ {children} @@ -190,7 +192,7 @@ function TabLink({ // underline variant (default) return ( - + {({ isActive, isPending }) => { return ( <> @@ -211,7 +213,7 @@ function TabLink({ className="mt-1 h-0.5 w-full bg-indigo-500" /> ) : ( -
+
)} ); @@ -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) { const ref = useRef(null); @@ -249,7 +254,7 @@ export function TabButton({ return ( ); diff --git a/apps/webapp/app/components/primitives/charts/ChartLine.tsx b/apps/webapp/app/components/primitives/charts/ChartLine.tsx index 3e14ca06c..5275c2d13 100644 --- a/apps/webapp/app/components/primitives/charts/ChartLine.tsx +++ b/apps/webapp/app/components/primitives/charts/ChartLine.tsx @@ -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({ - + {label}
@@ -274,12 +270,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 beafcbb70..77c534bd7 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 ?? []; @@ -49,7 +51,7 @@ function ToolDefRow({ def, wasCalled }: { def: ToolDefinition; wasCalled: boolea diff --git a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx index 53f561c65..c28ed7b49 100644 --- a/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx +++ b/apps/webapp/app/components/schedules/PurchaseSchedulesModal.tsx @@ -245,7 +245,7 @@ export function PurchaseSchedulesModal({ type="submit" disabled={isLoading} > - {`Send request for ${formatNumber( + {`Send request for ${formatNumber( amountValue )}`} @@ -259,7 +259,7 @@ export function PurchaseSchedulesModal({ disabled={isLoading || state === "need_to_delete"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Remove ${formatNumber( + {`Remove ${formatNumber( extraSchedules - amountValue )} ${extraSchedules - amountValue === 1 ? "schedule" : "schedules"}`} @@ -273,7 +273,7 @@ export function PurchaseSchedulesModal({ disabled={isLoading || state === "no_change"} LeadingIcon={isLoading ? SpinnerWhite : undefined} > - {`Purchase ${formatNumber( + {`Purchase ${formatNumber( amountValue - extraSchedules )} ${amountValue - extraSchedules === 1 ? "schedule" : "schedules"}`} diff --git a/apps/webapp/app/components/themeOptions.ts b/apps/webapp/app/components/themeOptions.ts new file mode 100644 index 000000000..3e287be61 --- /dev/null +++ b/apps/webapp/app/components/themeOptions.ts @@ -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; + +/** + * 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" +); diff --git a/apps/webapp/app/hooks/useSystemThemeSync.ts b/apps/webapp/app/hooks/useSystemThemeSync.ts index 6b2a67839..bfd168c6a 100644 --- a/apps/webapp/app/hooks/useSystemThemeSync.ts +++ b/apps/webapp/app/hooks/useSystemThemeSync.ts @@ -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 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]); } diff --git a/apps/webapp/app/hooks/useThemeMode.ts b/apps/webapp/app/hooks/useThemeMode.ts index e2d39d949..5145409e8 100644 --- a/apps/webapp/app/hooks/useThemeMode.ts +++ b/apps/webapp/app/hooks/useThemeMode.ts @@ -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(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("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); diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index ffca2ee0a..302e16c29 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -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) { + return prisma.user.update({ + where: { id }, + data: { name }, + }); +} + +export function updateUserEmail({ id, email }: Pick) { + 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) { + const { count } = await prisma.user.updateMany({ + where: { id, marketingEmails: { not: marketingEmails } }, + data: { marketingEmails }, + }); + + return { changed: count > 0 }; +} diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 9737ab58d..e5d4fae10 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -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) - : ({ 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, diff --git a/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts index c97d7ca92..a76c62fa2 100644 --- a/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts @@ -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 }; } diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index 4b268a56c..082376dbf 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -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 }; } diff --git a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts index 243bf7a9f..7f444a269 100644 --- a/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/UnifiedTaskListPresenter.server.ts @@ -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 = 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 = Promise.all([ - taskResult.runningStats, - agentResult.activeStates, - ]).then(([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates)); + const runningStates: Promise = backstopPromise( + Promise.all([taskResult.runningStats, agentResult.activeStates]).then( + ([runningStats, activeStates]) => mergeRunningStates(runningStats, activeStates) + ) + ); return { items, hourlyActivity, runningStates }; } diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx index b1d38ef2e..3cb547db4 100644 --- a/apps/webapp/app/root.tsx +++ b/apps/webapp/app/root.tsx @@ -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 ( - + @@ -170,29 +186,42 @@ export function ErrorBoundary() { } export default function App() { - const { posthogProjectKey, posthogUiHost, themePreference, themeContrast } = - useTypedLoaderData(); + const { + posthogProjectKey, + posthogUiHost, + themePreference, + themeContrast, + iconContrast, + underlineLinks, + systemThemes, + } = useTypedLoaderData(); 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 (