diff --git a/apps/builder/app/builder/features/style-panel/shared/color-picker.tsx b/apps/builder/app/builder/features/style-panel/shared/color-picker.tsx index c6e54878d160..2f6e86b33d0a 100644 --- a/apps/builder/app/builder/features/style-panel/shared/color-picker.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/color-picker.tsx @@ -25,7 +25,7 @@ import { toValue } from "@webstudio-is/css-engine"; import { theme } from "@webstudio-is/design-system"; import { CssValueInput } from "./css-value-input"; import type { IntermediateStyleValue } from "./css-value-input/css-value-input"; -import { ColorThumb } from "./color-thumb"; +import { ColorThumb } from "@webstudio-is/design-system"; // To support color names extend([namesPlugin]); diff --git a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx index 32e375a3735c..f95be7333462 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/css-value-input/css-value-input.tsx @@ -51,7 +51,7 @@ import { convertUnits } from "./convert-units"; import { mergeRefs } from "@react-aria/utils"; import { composeEventHandlers } from "~/shared/event-utils"; import type { StyleValueSourceColor } from "~/shared/style-object-model"; -import { ColorThumb } from "../color-thumb"; +import { ColorThumb } from "@webstudio-is/design-system"; import { cssButtonDisplay, isComplexValue, diff --git a/apps/builder/app/builder/features/style-panel/shared/repeated-style.tsx b/apps/builder/app/builder/features/style-panel/shared/repeated-style.tsx index 9f86f1c6085f..affb7532385f 100644 --- a/apps/builder/app/builder/features/style-panel/shared/repeated-style.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/repeated-style.tsx @@ -24,7 +24,7 @@ import { import { repeatUntil } from "~/shared/array-utils"; import type { ComputedStyleDecl } from "~/shared/style-object-model"; import { createBatchUpdate, type StyleUpdateOptions } from "./use-style-data"; -import { ColorThumb } from "./color-thumb"; +import { ColorThumb } from "@webstudio-is/design-system"; const isRepeatedValue = ( styleValue: StyleValue diff --git a/apps/builder/app/dashboard/projects/colors.ts b/apps/builder/app/dashboard/projects/colors.ts index 9b05f55fd9b6..cb39ae301c61 100644 --- a/apps/builder/app/dashboard/projects/colors.ts +++ b/apps/builder/app/dashboard/projects/colors.ts @@ -1,6 +1,20 @@ -export const colors = Array.from({ length: 50 }, (_, i) => { - const l = 55 + (i % 3) * 3; // Reduced variation in lightness (55-61%) to lower contrast - const c = 0.14 + (i % 2) * 0.02; // Reduced variation in chroma (0.14-0.16) for balance - const h = (i * 137.5) % 360; // Golden angle for pleasing hue distribution - return `oklch(${l}% ${c.toFixed(2)} ${h.toFixed(1)})`; -}); +export const DEFAULT_TAG_COLOR = "#6B6B6B"; + +export const colors = [ + "#D73A4A", // Red + "#F28B3E", // Orange + "#FBCA04", // Yellow + "#28A745", // Green + "#2088FF", // Teal + "#0366D6", // Blue + "#0052CC", // Indigo + "#8A63D2", // Purple + "#E99695", // Light Pink + "#F9D0C4", // Pink-ish Peach + "#F9E79F", // Pale Yellow + "#CCEBC5", // Light Green + "#D1E7DD", // Light Cyan + "#BFD7FF", // Light Blue + "#C7D2FE", // Azure Light + "#D8B4FE", // Lavender +] as const; diff --git a/apps/builder/app/dashboard/projects/project-card.tsx b/apps/builder/app/dashboard/projects/project-card.tsx index 82d796cf9c55..bbe7f4e2db31 100644 --- a/apps/builder/app/dashboard/projects/project-card.tsx +++ b/apps/builder/app/dashboard/projects/project-card.tsx @@ -32,6 +32,7 @@ import { Spinner } from "../shared/spinner"; import { Card, CardContent, CardFooter } from "../shared/card"; import type { User } from "~/shared/db/user.server"; import { TagsDialog } from "./tags"; +import { DEFAULT_TAG_COLOR } from "./colors"; const infoIconStyle = css({ flexShrink: 0 }); @@ -204,12 +205,13 @@ export const ProjectCard = ({ {projectsTags.map((tag) => { const isApplied = projectTagsIds.includes(tag.id); if (isApplied) { + const backgroundColor = tag.color ?? DEFAULT_TAG_COLOR; return ( { + const candidate = + typeof value === "string" ? value.trim().toLowerCase() : undefined; + if (candidate == null || candidate.length === 0) { + return undefined; + } + const normalized = candidate.startsWith("#") ? candidate : `#${candidate}`; + return /^#[0-9a-f]{6}$/.test(normalized) ? normalized : undefined; +}; + +const formatColorForInput = (value?: string) => { + if (value == null || value === "") { + return ""; + } + const normalized = normalizeHexColor(value); + return normalized ? normalized.toUpperCase() : value.toUpperCase(); +}; + +const getDisplayColor = (color: string | undefined) => + color ?? DEFAULT_TAG_COLOR; type DeleteConfirmationDialogProps = { onClose: () => void; @@ -139,7 +161,18 @@ const TagsList = ({ defaultChecked={projectTagsIds.includes(tag.id)} /> @@ -214,6 +247,13 @@ const TagEdit = ({ }) => { const revalidator = useRevalidator(); const isExisting = projectsTags.some(({ id }) => id === tag.id); + const [color, setColor] = useState(() => + formatColorForInput(tag.color ?? DEFAULT_TAG_COLOR) + ); + + useEffect(() => { + setColor(formatColorForInput(tag.color ?? DEFAULT_TAG_COLOR)); + }, [tag.color]); return (
{ if (availableTag.id === tag.id) { - return { ...availableTag, label }; + return { ...availableTag, label, color: normalizedColor }; } return availableTag; }); } else { - updatedTags = [...projectsTags, { id: tag.id, label }]; + updatedTags = [ + ...projectsTags, + { id: tag.id, label, color: normalizedColor }, + ]; } await nativeClient.user.updateProjectsTags.mutate({ @@ -243,14 +291,47 @@ const TagEdit = ({ onComplete(); }} > - - + + + + + + + + + { + setColor(event.target.value.toUpperCase()); + }} + placeholder="#AABBCC" + maxLength={7} + prefix={ + { + setColor(formatColorForInput(preset)); + }} + colors={tagColors} + aria-label="Pick tag color" + /> + } + aria-label="Tag color" + /> + ); }; diff --git a/apps/builder/app/shared/db/user.server.ts b/apps/builder/app/shared/db/user.server.ts index e25cc79ede83..16b14a5d6d85 100644 --- a/apps/builder/app/shared/db/user.server.ts +++ b/apps/builder/app/shared/db/user.server.ts @@ -110,6 +110,10 @@ export const createOrLoginWithDev = async ( export const userProjectTagSchema = z.object({ id: z.string(), label: z.string().min(1).max(100), + color: z + .string() + .regex(/^#[0-9a-f]{6}$/i, "Color must be a 6-digit hex value") + .optional(), }); export type ProjectTag = z.infer; diff --git a/apps/builder/app/builder/features/style-panel/shared/color-thumb.tsx b/packages/design-system/src/components/color-thumb.tsx similarity index 66% rename from apps/builder/app/builder/features/style-panel/shared/color-thumb.tsx rename to packages/design-system/src/components/color-thumb.tsx index 15a6d605a0ce..d2853966e689 100644 --- a/apps/builder/app/builder/features/style-panel/shared/color-thumb.tsx +++ b/packages/design-system/src/components/color-thumb.tsx @@ -1,15 +1,32 @@ -import { rawTheme, theme, css, type CSS } from "@webstudio-is/design-system"; +import { css, rawTheme, theme, type CSS } from "../stitches.config"; import { colord, type RgbaColor } from "colord"; -import { forwardRef, type ElementRef, type ComponentProps } from "react"; -import { clamp } from "~/shared/math-utils"; +import { forwardRef, type ComponentProps, type ElementRef } from "react"; + +const clamp = (value: number, min: number, max: number) => { + return Math.min(Math.max(value, min), max); +}; const whiteColor: RgbaColor = { r: 255, g: 255, b: 255, a: 1 }; const borderColorSwatch = colord(rawTheme.colors.borderColorSwatch).toRgb(); const transparentColor: RgbaColor = { r: 0, g: 0, b: 0, a: 0 }; +const toRgbaColor = (color?: RgbaColor | string): RgbaColor => { + if (color === undefined) { + return transparentColor; + } + + if (typeof color === "string") { + const parsed = colord(color); + if (parsed.isValid()) { + return parsed.toRgb(); + } + return transparentColor; + } + + return color; +}; + const distance = (a: RgbaColor, b: RgbaColor) => { - // Use Euclidian distance - // If results are not good, lets switch to https://zschuessler.github.io/DeltaE/ return Math.sqrt( Math.pow(a.r / 255 - b.r / 255, 2) + Math.pow(a.g / 255 - b.g / 255, 2) + @@ -18,19 +35,6 @@ const distance = (a: RgbaColor, b: RgbaColor) => { ); }; -// White color is invisible on white background, so we need to draw border -// the more color is white the more border is visible -const calcBorderColor = (color: RgbaColor) => { - const distanceToStartDrawBorder = 0.15; - const alpha = clamp( - (distanceToStartDrawBorder - distance(whiteColor, color)) / - distanceToStartDrawBorder, - 0, - 1 - ); - return colord(lerpColor(transparentColor, borderColorSwatch, alpha)); -}; - const lerp = (a: number, b: number, t: number) => { return a * (1 - t) + b * t; }; @@ -44,6 +48,17 @@ const lerpColor = (a: RgbaColor, b: RgbaColor, t: number) => { }; }; +const calcBorderColor = (color: RgbaColor) => { + const distanceToStartDrawBorder = 0.15; + const alpha = clamp( + (distanceToStartDrawBorder - distance(whiteColor, color)) / + distanceToStartDrawBorder, + 0, + 1 + ); + return colord(lerpColor(transparentColor, borderColorSwatch, alpha)); +}; + const thumbStyle = css({ display: "block", width: theme.spacing[9], @@ -60,19 +75,18 @@ const thumbStyle = css({ type Props = Omit, "color"> & { interactive?: boolean; - color?: RgbaColor; + color?: RgbaColor | string; css?: CSS; }; export const ColorThumb = forwardRef, Props>( - ({ interactive, color = transparentColor, css, ...rest }, ref) => { - const background = - color === undefined || color.a < 1 - ? // Chessboard pattern 5x5 - `repeating-conic-gradient(rgba(0,0,0,0.22) 0% 25%, transparent 0% 50%) 0% 33.33% / 40% 40%, ${colord(color).toRgbString()}` - : colord(color).toRgbString(); - const borderColor = calcBorderColor(color); - + ({ interactive, color, css, ...rest }, ref) => { + const resolvedColor = toRgbaColor(color); + const isTransparent = resolvedColor.a < 1; + const background = isTransparent + ? `repeating-conic-gradient(rgba(0,0,0,0.22) 0% 25%, transparent 0% 50%) 0% 33.33% / 40% 40%, ${colord(resolvedColor).toRgbString()}` + : colord(resolvedColor).toRgbString(); + const borderColor = calcBorderColor(resolvedColor); const Component = interactive ? "button" : "span"; return ( @@ -82,7 +96,6 @@ export const ColorThumb = forwardRef, Props>( style={{ background, borderColor: borderColor.toRgbString(), - // Border becomes visible when color is close to white so that the thumb is visible in the white input. borderWidth: borderColor.alpha() === 0 ? 0 : 1, }} className={thumbStyle({ css })} @@ -90,5 +103,4 @@ export const ColorThumb = forwardRef, Props>( ); } ); - ColorThumb.displayName = "ColorThumb"; diff --git a/packages/design-system/src/components/simple-color-picker.tsx b/packages/design-system/src/components/simple-color-picker.tsx new file mode 100644 index 000000000000..da3e59edea13 --- /dev/null +++ b/packages/design-system/src/components/simple-color-picker.tsx @@ -0,0 +1,111 @@ +import { + Popover, + PopoverClose, + PopoverContent, + PopoverTrigger, +} from "./popover"; +import { ColorThumb } from "./color-thumb"; +import { Flex } from "./flex"; +import { css, theme } from "../stitches.config"; +import { type ComponentProps } from "react"; + +export const defaultSimpleColorPickerColors = [ + "#D73A4A", // Red + "#F28B3E", // Orange + "#FBCA04", // Yellow + "#28A745", // Green + "#2088FF", // Teal + "#0366D6", // Blue + "#0052CC", // Indigo + "#8A63D2", // Purple + "#E99695", // Light Pink + "#F9D0C4", // Pink-ish Peach + "#F9E79F", // Pale Yellow + "#CCEBC5", // Light Green + "#D1E7DD", // Light Cyan + "#BFD7FF", // Light Blue + "#C7D2FE", // Azure Light + "#D8B4FE", // Lavender +] as const; + +type SimpleColorPickerProps = { + value?: string; + onChange?: (value: string) => void; + colors?: readonly string[]; + triggerProps?: ComponentProps; + "aria-label"?: string; +}; + +const swatchGridStyle = css({ + display: "grid", + gridTemplateColumns: "repeat(8, minmax(0, 1fr))", + columnGap: theme.spacing[2], + rowGap: theme.spacing[4], + justifyItems: "center", +}); + +export const SimpleColorPicker = ({ + value, + onChange, + colors = defaultSimpleColorPickerColors, + triggerProps, + "aria-label": ariaLabel = "Choose color", +}: SimpleColorPickerProps) => { + const normalizedValue = value?.toLowerCase(); + const swatchesClass = swatchGridStyle(); + return ( + + + + + + +
+ {colors.slice(0, 16).map((preset) => { + const normalizedPreset = preset.toLowerCase(); + const isActive = normalizedPreset === normalizedValue; + return ( + + { + onChange?.(preset); + }} + css={{ + width: theme.spacing[7], + height: theme.spacing[7], + borderColor: isActive + ? theme.colors.borderFocus + : undefined, + boxShadow: isActive + ? `0 0 0 1px ${theme.colors.borderFocus}` + : undefined, + }} + /> + + ); + })} +
+
+
+
+ ); +}; diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index fa609fc9a240..25b2430e9af5 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -40,6 +40,8 @@ export * from "./components/radio"; export * from "./components/checkbox"; export * from "./components/component-card"; export * from "./components/input-field"; +export * from "./components/color-thumb"; +export * from "./components/simple-color-picker"; export * from "./components/nested-input-button"; export * from "./components/panel-tabs"; export * from "./components/ai-command-bar";