Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 20 additions & 6 deletions apps/builder/app/dashboard/projects/colors.ts
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 3 additions & 1 deletion apps/builder/app/dashboard/projects/project-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down Expand Up @@ -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 (
<Text
color="contrast"
key={tag.id}
css={{
background: "oklch(0 0 0 / 0.3)",
background: backgroundColor,
borderRadius: theme.borderRadius[3],
paddingInline: theme.spacing[3],
}}
Expand Down
136 changes: 116 additions & 20 deletions apps/builder/app/dashboard/projects/tags.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRevalidator, useSearchParams } from "react-router-dom";
import { useState, type ComponentProps } from "react";
import { useEffect, useState, type ComponentProps } from "react";
import {
Text,
theme,
Expand All @@ -23,12 +23,34 @@ import {
SmallIconButton,
DropdownMenuContent,
DropdownMenuItem,
SimpleColorPicker,
} from "@webstudio-is/design-system";
import { nativeClient } from "~/shared/trpc/trpc-client";
import type { User } from "~/shared/db/user.server";
import { nanoid } from "nanoid";
import { EllipsesIcon, SpinnerIcon } from "@webstudio-is/icons";
import { colors } from "./colors";
import { EllipsesIcon, SpinnerIcon, CheckMarkIcon } from "@webstudio-is/icons";
import { colors as tagColors, DEFAULT_TAG_COLOR } from "./colors";

const normalizeHexColor = (value: string | null | undefined) => {
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;
Expand Down Expand Up @@ -139,7 +161,18 @@ const TagsList = ({
defaultChecked={projectTagsIds.includes(tag.id)}
/>
<Label truncate htmlFor={tag.id}>
{tag.label}
<Text
color="contrast"
key={tag.id}
css={{
background: getDisplayColor(tag.color),
borderRadius: theme.borderRadius[3],
paddingInline: theme.spacing[3],
width: "fit-content",
}}
>
{`#${tag.label}`}
</Text>
</Label>
</CheckboxAndLabel>
<DropdownMenu modal>
Expand Down Expand Up @@ -214,26 +247,41 @@ 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 (
<form
onSubmit={async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const label = ((formData.get("tag") as string) || "").trim();
if (tag.label === label || !label) {
const normalizedColor =
normalizeHexColor(color) ??
normalizeHexColor(tag.color) ??
DEFAULT_TAG_COLOR;

if ((tag.label === label && tag.color === normalizedColor) || !label) {
return;
}
let updatedTags = [];
if (isExisting) {
updatedTags = projectsTags.map((availableTag) => {
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({
Expand All @@ -243,14 +291,47 @@ const TagEdit = ({
onComplete();
}}
>
<Grid css={{ padding: theme.panel.padding }}>
<InputField
autoFocus
defaultValue={tag.label}
name="tag"
placeholder="My tag"
minLength={1}
/>
<Grid
css={{
padding: theme.panel.padding,
gap: theme.spacing[8],
}}
>
<Flex direction="column" gap="2">
<Label htmlFor="tagLabel">Label</Label>
<InputField
autoFocus
defaultValue={tag.label}
name="tag"
placeholder="My tag"
minLength={1}
/>
</Flex>

<Flex direction="column" gap="2">
<Label htmlFor="tagColor">Color</Label>
<InputField
id="tagColor"
name="tagColor"
value={color}
onChange={(event) => {
setColor(event.target.value.toUpperCase());
}}
placeholder="#AABBCC"
maxLength={7}
prefix={
<SimpleColorPicker
value={color}
onChange={(preset) => {
setColor(formatColorForInput(preset));
}}
colors={tagColors}
aria-label="Pick tag color"
/>
}
aria-label="Tag color"
/>
</Flex>
</Grid>
<DialogActions>
<Button type="submit">
Expand Down Expand Up @@ -340,17 +421,25 @@ export const Tag = ({
>) => {
const [searchParams, setSearchParams] = useSearchParams();
const selectedTagsIds = searchParams.getAll("tag");
const color = colors[index] ?? theme.colors.backgroundNeutralDark;
const isSelected = selectedTagsIds.includes(tag.id);
const color = getDisplayColor(tag.color);
return (
<Button
color="neutral"
css={{
"&:hover[data-state='auto'], &[data-state='pressed']": {
backgroundColor: color,
backgroundColor: color,
color: theme.colors.white,
"&:hover[data-state='auto']": {
backgroundColor: `oklch(from ${color} l c h / 0.9)`,
color: theme.colors.white,
},
"&[data-state='pressed']": {
backgroundColor: `oklch(from ${color} l c h / 0.65)`,
color: theme.colors.white,
},
"&[data-state='pressed']:hover": {
backgroundColor: `oklch(from ${color} l c h / 0.8)`,
backgroundColor: `oklch(from ${color} l c h / 0.6)`,
color: theme.colors.white,
},
}}
onClick={() => {
Expand All @@ -367,6 +456,13 @@ export const Tag = ({
setSearchParams(newSearchParams);
}}
{...props}
/>
>
<Flex align="center" gap="2">
{isSelected && (
<CheckMarkIcon color={theme.colors.white} style={{ flexShrink: 0 }} />
)}
<span>{`#${tag.label}`}</span>
</Flex>
</Button>
);
};
4 changes: 4 additions & 0 deletions apps/builder/app/shared/db/user.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof userProjectTagSchema>;
Expand Down
Loading
Loading