diff --git a/.vscode/settings.json b/.vscode/settings.json index 82ce10e888010..b3f595bde2d94 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -120,6 +120,7 @@ "stretchr", "STTY", "stuntest", + "subpage", "tailbroker", "tailcfg", "tailexchange", diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx new file mode 100644 index 0000000000000..330b3c9a41105 --- /dev/null +++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { FeatureStageBadge } from "./FeatureStageBadge"; + +const meta: Meta = { + title: "components/FeatureStageBadge", + component: FeatureStageBadge, + args: { + contentType: "beta", + }, +}; + +export default meta; +type Story = StoryObj; + +export const MediumBeta: Story = { + args: { + size: "md", + }, +}; + +export const SmallBeta: Story = { + args: { + size: "sm", + }, +}; + +export const LargeBeta: Story = { + args: { + size: "lg", + }, +}; + +export const MediumExperimental: Story = { + args: { + size: "md", + contentType: "experimental", + }, +}; diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx new file mode 100644 index 0000000000000..92c230a4a23f7 --- /dev/null +++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx @@ -0,0 +1,133 @@ +import type { Interpolation, Theme } from "@emotion/react"; +import Link from "@mui/material/Link"; +import { visuallyHidden } from "@mui/utils"; +import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip"; +import { Popover, PopoverTrigger } from "components/Popover/Popover"; +import type { FC, HTMLAttributes, ReactNode } from "react"; +import { docs } from "utils/docs"; + +/** + * All types of feature that we are currently supporting. Defined as record to + * ensure that we can't accidentally make typos when writing the badge text. + */ +const featureStageBadgeTypes = { + beta: "beta", + experimental: "experimental", +} as const satisfies Record; + +type FeatureStageBadgeProps = Readonly< + Omit, "children"> & { + contentType: keyof typeof featureStageBadgeTypes; + size?: "sm" | "md" | "lg"; + } +>; + +export const FeatureStageBadge: FC = ({ + contentType, + size = "md", + ...delegatedProps +}) => { + return ( + + + {({ isOpen }) => ( + + (This is a + {featureStageBadgeTypes[contentType]} + feature) + + )} + + + +

+ This feature has not yet reached general availability (GA). +

+ + + Learn about feature stages + (link opens in new tab) + +
+
+ ); +}; + +const styles = { + badge: (theme) => ({ + // Base type is based on a span so that the element can be placed inside + // more types of HTML elements without creating invalid markdown, but we + // still want the default display behavior to be div-like + display: "block", + maxWidth: "fit-content", + + // Base style assumes that medium badges will be the default + fontSize: "0.75rem", + + cursor: "default", + flexShrink: 0, + padding: "4px 8px", + lineHeight: 1, + whiteSpace: "nowrap", + border: `1px solid ${theme.branding.featureStage.border}`, + color: theme.branding.featureStage.text, + backgroundColor: theme.branding.featureStage.background, + borderRadius: "6px", + transition: + "color 0.2s ease-in-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out", + }), + + badgeHover: (theme) => ({ + color: theme.branding.featureStage.hover.text, + borderColor: theme.branding.featureStage.hover.border, + backgroundColor: theme.branding.featureStage.hover.background, + }), + + badgeLargeText: { + fontSize: "1rem", + }, + + badgeSmallText: { + // Have to beef up font weight so that the letters still maintain the + // same relative thickness as all our other main UI text + fontWeight: 500, + fontSize: "0.625rem", + }, + + tooltipTitle: (theme) => ({ + color: theme.palette.text.primary, + fontWeight: 600, + fontFamily: "inherit", + fontSize: 18, + margin: 0, + lineHeight: 1, + paddingBottom: "8px", + }), + + tooltipDescription: { + margin: 0, + lineHeight: 1.4, + paddingBottom: "8px", + }, + + tooltipLink: { + fontWeight: 600, + lineHeight: 1.2, + }, +} as const satisfies Record>; diff --git a/site/src/components/Form/Form.tsx b/site/src/components/Form/Form.tsx index 286c307b6231b..7286e0df1e700 100644 --- a/site/src/components/Form/Form.tsx +++ b/site/src/components/Form/Form.tsx @@ -170,7 +170,7 @@ const styles = { formSectionInfoTitle: (theme) => ({ fontSize: 20, color: theme.palette.text.primary, - fontWeight: 400, + fontWeight: 500, margin: 0, marginBottom: 8, display: "flex", diff --git a/site/src/components/Popover/Popover.tsx b/site/src/components/Popover/Popover.tsx index 7db3c4eda1799..654be6d7931ae 100644 --- a/site/src/components/Popover/Popover.tsx +++ b/site/src/components/Popover/Popover.tsx @@ -1,16 +1,19 @@ import MuiPopover, { type PopoverProps as MuiPopoverProps, - // biome-ignore lint/nursery/noRestrictedImports: Used as base component + // biome-ignore lint/nursery/noRestrictedImports: This is the base component that our custom popover is based on } from "@mui/material/Popover"; import { type FC, type HTMLAttributes, + type PointerEvent, + type PointerEventHandler, type ReactElement, type ReactNode, type RefObject, cloneElement, createContext, useContext, + useEffect, useId, useRef, useState, @@ -20,10 +23,13 @@ type TriggerMode = "hover" | "click"; type TriggerRef = RefObject; -type TriggerElement = ReactElement<{ - ref: TriggerRef; - onClick?: () => void; -}>; +// Have to append ReactNode type to satisfy React's cloneElement function. It +// has absolutely no bearing on what happens at runtime +type TriggerElement = ReactNode & + ReactElement<{ + ref: TriggerRef; + onClick?: () => void; + }>; type PopoverContextValue = { id: string; @@ -61,6 +67,15 @@ export const Popover: FC = (props) => { const [uncontrolledOpen, setUncontrolledOpen] = useState(false); const triggerRef: TriggerRef = useRef(null); + // Helps makes sure that popovers close properly when the user switches to + // a different tab. This won't help with controlled instances of the + // component, but this is basically the most we can do from here + useEffect(() => { + const closeOnTabSwitch = () => setUncontrolledOpen(false); + window.addEventListener("blur", closeOnTabSwitch); + return () => window.removeEventListener("blur", closeOnTabSwitch); + }, []); + const value: PopoverContextValue = { triggerRef, id: `${hookId}-popover`, @@ -86,30 +101,47 @@ export const usePopover = () => { return context; }; -export const PopoverTrigger = ( - props: HTMLAttributes & { - children: TriggerElement; - }, -) => { +type PopoverTriggerRenderProps = Readonly<{ + isOpen: boolean; +}>; + +type PopoverTriggerProps = Readonly< + Omit, "children"> & { + children: + | TriggerElement + | ((props: PopoverTriggerRenderProps) => TriggerElement); + } +>; + +export const PopoverTrigger: FC = (props) => { const popover = usePopover(); - const { children, ...elementProps } = props; + const { children, onClick, onPointerEnter, onPointerLeave, ...elementProps } = + props; const clickProps = { - onClick: () => { + onClick: (event: PointerEvent) => { popover.setOpen(true); + onClick?.(event); }, }; const hoverProps = { - onPointerEnter: () => { + onPointerEnter: (event: PointerEvent) => { popover.setOpen(true); + onPointerEnter?.(event); }, - onPointerLeave: () => { + onPointerLeave: (event: PointerEvent) => { popover.setOpen(false); + onPointerLeave?.(event); }, }; - return cloneElement(props.children, { + const evaluatedChildren = + typeof children === "function" + ? children({ isOpen: popover.open }) + : children; + + return cloneElement(evaluatedChildren, { ...elementProps, ...(popover.mode === "click" ? clickProps : hoverProps), "aria-haspopup": true, @@ -130,6 +162,8 @@ export type PopoverContentProps = Omit< export const PopoverContent: FC = ({ horizontal = "left", + onPointerEnter, + onPointerLeave, ...popoverProps }) => { const popover = usePopover(); @@ -152,7 +186,7 @@ export const PopoverContent: FC = ({ }, }} {...horizontalProps(horizontal)} - {...modeProps(popover)} + {...modeProps(popover, onPointerEnter, onPointerLeave)} {...popoverProps} id={popover.id} open={popover.open} @@ -162,14 +196,20 @@ export const PopoverContent: FC = ({ ); }; -const modeProps = (popover: PopoverContextValue) => { +const modeProps = ( + popover: PopoverContextValue, + externalOnPointerEnter: PointerEventHandler | undefined, + externalOnPointerLeave: PointerEventHandler | undefined, +) => { if (popover.mode === "hover") { return { - onPointerEnter: () => { + onPointerEnter: (event: PointerEvent) => { popover.setOpen(true); + externalOnPointerEnter?.(event); }, - onPointerLeave: () => { + onPointerLeave: (event: PointerEvent) => { popover.setOpen(false); + externalOnPointerLeave?.(event); }, }; } diff --git a/site/src/components/SettingsHeader/SettingsHeader.tsx b/site/src/components/SettingsHeader/SettingsHeader.tsx index ea68415cc1e5b..30ed5b0c527a0 100644 --- a/site/src/components/SettingsHeader/SettingsHeader.tsx +++ b/site/src/components/SettingsHeader/SettingsHeader.tsx @@ -10,6 +10,7 @@ interface HeaderProps { secondary?: boolean; docsHref?: string; tooltip?: ReactNode; + badges?: ReactNode; } export const SettingsHeader: FC = ({ @@ -18,35 +19,40 @@ export const SettingsHeader: FC = ({ docsHref, secondary, tooltip, + badges, }) => { const theme = useTheme(); return (
- -

- {title} -

- {tooltip} + + +

+ {title} +

+ {tooltip} +
+ {badges}
+ {description && ( css` text-decoration: none; color: inherit; - gap: 20px; + gap: 8px; padding: 8px 20px; font-size: 14px; diff --git a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx index beff71098fbac..5e845e7bb78a5 100644 --- a/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx +++ b/site/src/pages/ManagementSettingsPage/CustomRolesPage/CustomRolesPage.tsx @@ -5,6 +5,7 @@ import { organizationPermissions } from "api/queries/organizations"; import { deleteOrganizationRole, organizationRoles } from "api/queries/roles"; import type { Role } from "api/typesGenerated"; import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"; +import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { displayError, displaySuccess } from "components/GlobalSnackbar/utils"; import { Loader } from "components/Loader/Loader"; import { SettingsHeader } from "components/SettingsHeader/SettingsHeader"; @@ -66,6 +67,7 @@ export const CustomRolesPage: FC = () => { } /> {permissions.assignOrgRole && isCustomRolesEnabled && ( - } + - Provisioners - + } + /> + + {isEmpty ? ( - + } + /> {Boolean(error) && !isApiValidationError(error) && (
diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.tsx index 795458794bc53..01610596f7ffa 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.tsx @@ -7,6 +7,7 @@ import type { Experiments, Organization, } from "api/typesGenerated"; +import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { Loader } from "components/Loader/Loader"; import { Sidebar as BaseSidebar } from "components/Sidebar/Sidebar"; import { Stack } from "components/Stack/Stack"; @@ -47,7 +48,10 @@ export const SidebarView: FC = ({ // TODO: Do something nice to scroll to the active org. return ( -
Deployment
+
+

Deployment

+
+ -
Organizations
+
+

Organizations

+ +
+ {permissions.createOrganization && ( = ({ const styles = { sidebarHeader: { textTransform: "uppercase", - letterSpacing: "0.15em", + letterSpacing: "0.1em", + margin: 0, fontSize: 11, fontWeight: 500, paddingBottom: 4, @@ -396,7 +412,7 @@ const classNames = { `, subLink: (css, theme) => css` - color: inherit; + color: ${theme.palette.text.secondary}; text-decoration: none; display: block; @@ -409,11 +425,13 @@ const classNames = { position: relative; &:hover { + color: ${theme.palette.text.primary}; background-color: ${theme.palette.action.hover}; } `, - activeSubLink: (css) => css` + activeSubLink: (css, theme) => css` + color: ${theme.palette.text.primary}; font-weight: 600; `, } satisfies Record; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx index a4e520c2b7137..6559e31723156 100644 --- a/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx +++ b/site/src/pages/UserSettingsPage/AppearancePage/AppearanceForm.tsx @@ -175,23 +175,23 @@ const ThemePreview: FC = ({
-
-
-
+
+
+
-
-
+
+
-
+
-
-
-
-
-
+
+
+
+
+
diff --git a/site/src/theme/branding.ts b/site/src/theme/branding.ts index c1e42e5111abd..ae593e59a3e03 100644 --- a/site/src/theme/branding.ts +++ b/site/src/theme/branding.ts @@ -1,14 +1,29 @@ -export interface Branding { - enterprise: { +export type Branding = Readonly<{ + enterprise: Readonly<{ background: string; divider: string; border: string; text: string; - }; - premium: { + }>; + + premium: Readonly<{ background: string; divider: string; border: string; text: string; - }; -} + }>; + + featureStage: Readonly<{ + background: string; + divider: string; + border: string; + text: string; + + hover: Readonly<{ + background: string; + divider: string; + border: string; + text: string; + }>; + }>; +}>; diff --git a/site/src/theme/dark/branding.ts b/site/src/theme/dark/branding.ts index cc0b603b62f0c..614bf630b51bf 100644 --- a/site/src/theme/dark/branding.ts +++ b/site/src/theme/dark/branding.ts @@ -1,7 +1,7 @@ import type { Branding } from "../branding"; import colors from "../tailwindColors"; -export default { +export const branding: Branding = { enterprise: { background: colors.blue[950], divider: colors.blue[900], @@ -14,4 +14,20 @@ export default { border: colors.violet[400], text: colors.violet[50], }, -} satisfies Branding; + + featureStage: { + background: colors.sky[950], + divider: colors.sky[900], + border: colors.sky[400], + text: colors.sky[400], + + hover: { + background: colors.zinc[950], + divider: colors.zinc[900], + border: colors.sky[400], + text: colors.sky[400], + }, + }, +}; + +export default branding; diff --git a/site/src/theme/dark/roles.ts b/site/src/theme/dark/roles.ts index 32a9ea4f12992..ec59dfd75e4ba 100644 --- a/site/src/theme/dark/roles.ts +++ b/site/src/theme/dark/roles.ts @@ -1,7 +1,7 @@ import type { Roles } from "../roles"; import colors from "../tailwindColors"; -export default { +const roles: Roles = { danger: { background: colors.orange[950], outline: colors.orange[500], @@ -152,4 +152,6 @@ export default { text: colors.white, }, }, -} satisfies Roles; +}; + +export default roles; diff --git a/site/src/theme/darkBlue/branding.ts b/site/src/theme/darkBlue/branding.ts index cc0b603b62f0c..c59079b58a3e6 100644 --- a/site/src/theme/darkBlue/branding.ts +++ b/site/src/theme/darkBlue/branding.ts @@ -1,7 +1,7 @@ import type { Branding } from "../branding"; import colors from "../tailwindColors"; -export default { +export const branding: Branding = { enterprise: { background: colors.blue[950], divider: colors.blue[900], @@ -14,4 +14,20 @@ export default { border: colors.violet[400], text: colors.violet[50], }, -} satisfies Branding; + + featureStage: { + background: colors.sky[900], + divider: colors.sky[800], + border: colors.sky[400], + text: colors.sky[400], + + hover: { + background: colors.gray[900], + divider: colors.gray[800], + border: colors.sky[400], + text: colors.sky[400], + }, + }, +}; + +export default branding; diff --git a/site/src/theme/darkBlue/roles.ts b/site/src/theme/darkBlue/roles.ts index 744b7329249b9..413398ca6db45 100644 --- a/site/src/theme/darkBlue/roles.ts +++ b/site/src/theme/darkBlue/roles.ts @@ -1,7 +1,7 @@ import type { Roles } from "../roles"; import colors from "../tailwindColors"; -export default { +const roles: Roles = { danger: { background: colors.orange[950], outline: colors.orange[500], @@ -152,4 +152,6 @@ export default { text: colors.white, }, }, -} satisfies Roles; +}; + +export default roles; diff --git a/site/src/theme/light/branding.ts b/site/src/theme/light/branding.ts index 97b6df71def0e..a23e43239355f 100644 --- a/site/src/theme/light/branding.ts +++ b/site/src/theme/light/branding.ts @@ -1,7 +1,7 @@ import type { Branding } from "../branding"; import colors from "../tailwindColors"; -export default { +export const branding: Branding = { enterprise: { background: colors.blue[100], divider: colors.blue[300], @@ -14,4 +14,20 @@ export default { border: colors.violet[600], text: colors.violet[950], }, -} satisfies Branding; + + featureStage: { + background: colors.sky[50], + divider: colors.sky[100], + border: colors.sky[700], + text: colors.sky[700], + + hover: { + background: colors.white, + divider: colors.zinc[100], + border: colors.sky[700], + text: colors.sky[700], + }, + }, +}; + +export default branding; diff --git a/site/src/theme/light/roles.ts b/site/src/theme/light/roles.ts index fe3d1d9687bfa..ce04ab554798a 100644 --- a/site/src/theme/light/roles.ts +++ b/site/src/theme/light/roles.ts @@ -1,7 +1,7 @@ import type { Roles } from "../roles"; import colors from "../tailwindColors"; -export default { +const roles: Roles = { danger: { background: colors.orange[50], outline: colors.orange[400], @@ -152,4 +152,6 @@ export default { text: colors.white, }, }, -} satisfies Roles; +}; + +export default roles;