Add "light-brand" and "dark-brand" colors (add a second color picker) (#2028)

* init dark brand color addition

* added dark mode css vars

* added contrast brand colors

* minor fixes

* added dark branding to loader, button

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Syed Ali Shahbaz 2022-03-05 21:07:46 +05:30 committed by GitHub
parent ce0c8347fb
commit 8c4eed2bbc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 96 additions and 35 deletions

View file

@ -18,7 +18,7 @@ export default function AddToHomescreen() {
<div className="rounded-lg p-2 shadow-lg sm:p-3" style={{ background: "#2F333D" }}>
<div className="flex flex-wrap items-center justify-between">
<div className="flex w-0 flex-1 items-center">
<span className="bg-brand text-brandcontrast flex rounded-lg bg-opacity-30 p-2">
<span className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast flex rounded-lg bg-opacity-30 p-2">
<svg
className="h-7 w-7 fill-current text-indigo-500"
xmlns="http://www.w3.org/2000/svg"

View file

@ -2,6 +2,7 @@ import { useEffect } from "react";
const brandColor = "#292929";
const brandTextColor = "#ffffff";
const darkBrandColor = "#fafafa";
export function colorNameToHex(color: string) {
const colors = {
@ -174,8 +175,8 @@ function hexToRGB(hex: string) {
return [parseInt(color.slice(0, 2), 16), parseInt(color.slice(2, 4), 16), parseInt(color.slice(4, 6), 16)];
}
function getContrastingTextColor(bgColor: string | null): string {
bgColor = bgColor == "" || bgColor == null ? brandColor : bgColor;
function getContrastingTextColor(bgColor: string | null, dark: boolean): string {
bgColor = bgColor == "" || bgColor == null ? (dark ? darkBrandColor : brandColor) : bgColor;
const rgb = hexToRGB(bgColor);
const whiteContrastRatio = computeContrastRatio(rgb, [255, 255, 255]);
const blackContrastRatio = computeContrastRatio(rgb, [41, 41, 41]); //#292929
@ -191,18 +192,38 @@ export function isValidHexCode(val: string | null) {
return false;
}
export function fallBackHex(val: string | null): string {
export function fallBackHex(val: string | null, dark: boolean): string {
if (val) if (colorNameToHex(val)) return colorNameToHex(val) as string;
return brandColor;
return dark ? darkBrandColor : brandColor;
}
const BrandColor = ({ val = brandColor }: { val: string | undefined | null }) => {
const BrandColor = ({
lightVal = brandColor,
darkVal = darkBrandColor,
}: {
lightVal: string | undefined | null;
darkVal: string | undefined | null;
}) => {
// ensure acceptable hex-code
val = isValidHexCode(val) ? (val?.indexOf("#") === 0 ? val : "#" + val) : fallBackHex(val);
lightVal = isValidHexCode(lightVal)
? lightVal?.indexOf("#") === 0
? lightVal
: "#" + lightVal
: fallBackHex(lightVal, false);
darkVal = isValidHexCode(darkVal)
? darkVal?.indexOf("#") === 0
? darkVal
: "#" + darkVal
: fallBackHex(darkVal, true);
useEffect(() => {
document.documentElement.style.setProperty("--brand-color", val);
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(val));
}, [val]);
document.documentElement.style.setProperty("--brand-color", lightVal);
document.documentElement.style.setProperty("--brand-text-color", getContrastingTextColor(lightVal, true));
document.documentElement.style.setProperty("--brand-color-dark-mode", darkVal);
document.documentElement.style.setProperty(
"--brand-text-color-dark-mode",
getContrastingTextColor(darkVal, true)
);
}, [lightVal, darkVal]);
return null;
};

View file

@ -1,7 +1,7 @@
export default function Loader() {
return (
<div className="loader border-brand dark:border-white">
<span className="loader-inner bg-brand dark:bg-white"></span>
<div className="loader border-brand dark:border-darkmodebrand">
<span className="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
);
}

View file

@ -192,7 +192,7 @@ export default function Shell(props: {
}
return (
<>
<CustomBranding val={user?.brandColor} />
<CustomBranding lightVal={user?.brandColor} darkVal={user?.darkBrandColor} />
<HeadSeo
title={pageTitle ?? "Cal.com"}
description={props.subtitle ? props.subtitle?.toString() : ""}

View file

@ -101,7 +101,7 @@ const AvailableTimes: FC<AvailableTimesProps> = ({
<Link href={bookingUrl}>
<a
className={classNames(
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-brand dark:hover:text-brandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
"text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 block rounded-sm border bg-white py-4 font-medium hover:text-white dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black",
brand === "#fff" || brand === "#ffffff" ? "border-brandcontrast" : "border-brand"
)}
data-testid="time">

View file

@ -278,7 +278,7 @@ function DatePicker({
"hover:border-brand hover:border dark:hover:border-white",
day.disabled ? "cursor-default font-light text-gray-400 hover:border-0" : "font-medium",
date && date.isSame(browsingDate.date(day.date), "day")
? "bg-brand text-brandcontrast"
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
: !day.disabled
? " bg-gray-100 dark:bg-gray-600 dark:text-white"
: ""

View file

@ -46,7 +46,9 @@ const TimeOptions: FC<Props> = ({ onToggle24hClock, onSelectTimeZone }) => {
checked={is24hClock}
onChange={handle24hClockToggle}
className={classNames(
is24hClock ? "bg-brand text-brandcontrast" : "bg-gray-200 dark:bg-gray-600",
is24hClock
? "bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast"
: "bg-gray-200 dark:bg-gray-600",
"relative inline-flex h-5 w-8 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2"
)}>
<span className="sr-only">{t("use_setting")}</span>

View file

@ -110,7 +110,7 @@ const AvailabilityPage = ({ profile, eventType, workingHours }: Props) => {
username={profile.slug || undefined}
// avatar={profile.image || undefined}
/>
<CustomBranding val={profile.brandColor} />
<CustomBranding lightVal={profile.brandColor} darkVal={profile.darkBrandColor} />
<div>
<main
className={

View file

@ -280,7 +280,7 @@ const BookingPage = (props: BookingPageProps) => {
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<CustomBranding val={props.profile.brandColor} />
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className="mx-auto my-0 max-w-3xl rounded-sm sm:my-24 sm:border sm:dark:border-gray-600">
{isReady && (
<div className="overflow-hidden border border-gray-200 bg-white dark:border-0 dark:bg-neutral-900 sm:rounded-sm">

View file

@ -4,7 +4,7 @@ import React from "react";
const TwoFactorModalHeader = ({ title, description }: { title: string; description: string }) => {
return (
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<ShieldCheckIcon className="h-6 w-6 text-black" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">

View file

@ -64,7 +64,7 @@ export default function MemberInvitationModal(props: { team: TeamWithMembers | n
<div className="inline-block transform rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6 sm:align-middle">
<div className="mb-4 sm:flex sm:items-start">
<div className="bg-brand text-brandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<div className="bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-opacity-5 sm:mx-0 sm:h-10 sm:w-10">
<UserIcon className="text-brandcontrast h-6 w-6" />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">

View file

@ -33,7 +33,7 @@ export const WeekdaySelect = (props: WeekdaySelectProps) => {
toggleDay(idx);
}}
className={`
bg-brand text-brandcontrast
bg-brand text-brandcontrast dark:bg-darkmodebrand dark:text-darkmodebrandcontrast
h-10 w-10 rounded px-3 py-1 focus:outline-none
${activeDays[idx + 1] ? "rounded-r-none" : ""}
${activeDays[idx - 1] ? "rounded-l-none" : ""}

View file

@ -62,7 +62,9 @@ export type ColorPickerProps = {
};
const ColorPicker = (props: ColorPickerProps) => {
const init = !isValidHexCode(props.defaultValue) ? fallBackHex(props.defaultValue) : props.defaultValue;
const init = !isValidHexCode(props.defaultValue)
? fallBackHex(props.defaultValue, false)
: props.defaultValue;
const [color, setColor] = useState(init);
const [isOpen, toggle] = useState(false);
const popover = useRef() as React.MutableRefObject<HTMLInputElement>;

View file

@ -59,7 +59,7 @@ export default function TeamAvailabilityTimes(props: Props) {
{times.map((time) => (
<div key={time.format()} className="flex flex-row items-center">
<a
className="min-w-48 border-brand text-primary-500 hover:bg-brand hover:text-brandcontrast mb-2 mr-3 block flex-grow rounded-sm border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black dark:hover:bg-black dark:hover:text-white"
className="min-w-48 border-brand text-primary-500 hover:bg-brand hover:text-brandcontrast dark:hover:bg-darkmodebrand dark:hover:text-darkmodebrandcontrast mb-2 mr-3 block flex-grow rounded-sm border bg-white py-2 text-center font-medium dark:border-transparent dark:bg-gray-600 dark:text-neutral-200 dark:hover:border-black dark:hover:bg-black dark:hover:text-white"
data-testid="time">
{time.format("HH:mm")}
</a>

View file

@ -79,6 +79,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
availability: true,
hideBranding: true,
brandColor: true,
darkBrandColor: true,
theme: true,
plan: true,
eventTypes: {
@ -192,6 +193,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
theme: user.theme,
weekStart: user.weekStart,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
},
date: dateParam,
eventType: eventTypeObject,

View file

@ -37,6 +37,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
avatar: true,
theme: true,
brandColor: true,
darkBrandColor: true,
},
});
@ -140,6 +141,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: user.avatar,
theme: user.theme,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
},
eventType: eventTypeObject,
booking,

View file

@ -64,8 +64,8 @@ const AvailabilityView = ({ user }: { user: User }) => {
/>
<small className="block text-neutral-400">{t("hover_over_bold_times_tip")}</small>
<div className="mt-4 space-y-4">
<div className="bg-brand overflow-hidden rounded-sm">
<div className="text-brandcontrast px-4 py-2 sm:px-6">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_starts_at")} {convertMinsToHrsMins(user.startTime)}
</div>
</div>
@ -94,8 +94,8 @@ const AvailabilityView = ({ user }: { user: User }) => {
</div>
)}
<div className="bg-brand overflow-hidden rounded-sm">
<div className="text-brandcontrast px-4 py-2 sm:px-6">
<div className="bg-brand dark:bg-darkmodebrand overflow-hidden rounded-sm">
<div className="text-brandcontrast dark:text-darkmodebrandcontrast px-4 py-2 sm:px-6">
{t("your_day_ends_at")} {convertMinsToHrsMins(user.endTime)}
</div>
</div>

View file

@ -35,7 +35,7 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
title={`${t("cancel")} ${props.booking && props.booking.title} | ${props.profile?.name}`}
description={`${t("cancel")} ${props.booking && props.booking.title} | ${props.profile?.name}`}
/>
<CustomBranding val={props.profile?.brandColor} />
<CustomBranding lightVal={props.profile?.brandColor} darkVal={props.profile?.darkBrandColor} />
<main className="mx-auto my-24 max-w-3xl">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
@ -179,6 +179,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
username: true,
name: true,
brandColor: true,
darkBrandColor: true,
},
},
eventType: {
@ -210,6 +211,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
name: booking.eventType?.team?.name || booking.user?.name || null,
slug: booking.eventType?.team?.slug || booking.user?.username || null,
brandColor: booking.user?.brandColor || null,
darkBrandColor: booking.user?.darkBrandColor || null,
};
return {

View file

@ -176,6 +176,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const [hasErrors, setHasErrors] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const [brandColor, setBrandColor] = useState(props.user.brandColor);
const [darkBrandColor, setDarkBrandColor] = useState(props.user.darkBrandColor);
useEffect(() => {
if (!props.user.theme) return;
@ -194,6 +195,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
const enteredDescription = descriptionRef.current.value;
const enteredAvatar = avatarRef.current.value;
const enteredBrandColor = brandColor;
const enteredDarkBrandColor = darkBrandColor;
const enteredTimeZone = typeof selectedTimeZone === "string" ? selectedTimeZone : selectedTimeZone.value;
const enteredWeekStartDay = selectedWeekStartDay.value;
const enteredHideBranding = hideBrandingRef.current.checked;
@ -213,6 +215,7 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
hideBranding: enteredHideBranding,
theme: asStringOrNull(selectedTheme?.value),
brandColor: enteredBrandColor,
darkBrandColor: enteredDarkBrandColor,
locale: enteredLanguage,
timeFormat: enteredTimeFormat,
});
@ -424,11 +427,19 @@ function SettingsView(props: ComponentProps<typeof Settings> & { localeProp: str
</div>
</div>
</div>
<div>
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
{t("brand_color")}
</label>
<ColorPicker defaultValue={props.user.brandColor} onChange={setBrandColor} />
<div className="block rtl:space-x-reverse sm:flex sm:space-x-2">
<div className="mb-6 w-full sm:w-1/2">
<label htmlFor="brandColor" className="block text-sm font-medium text-gray-700">
{t("light_brand_color")}
</label>
<ColorPicker defaultValue={props.user.brandColor} onChange={setBrandColor} />
</div>
<div className="mb-6 w-full sm:w-1/2">
<label htmlFor="darkBrandColor" className="block text-sm font-medium text-gray-700">
{t("dark_brand_color")}
</label>
<ColorPicker defaultValue={props.user.darkBrandColor} onChange={setDarkBrandColor} />
</div>
<hr className="mt-6" />
</div>
<div>
@ -524,6 +535,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
theme: true,
plan: true,
brandColor: true,
darkBrandColor: true,
metadata: true,
timeFormat: true,
},

View file

@ -96,7 +96,7 @@ export default function Success(props: inferSSRProps<typeof getServerSideProps>)
title={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
description={needsConfirmation ? t("booking_submitted") : t("booking_confirmed")}
/>
<CustomBranding val={props.profile.brandColor} />
<CustomBranding lightVal={props.profile.brandColor} darkVal={props.profile.darkBrandColor} />
<main className="mx-auto max-w-3xl py-24">
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
@ -320,6 +320,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
plan: true,
theme: true,
brandColor: true,
darkBrandColor: true,
},
},
team: {
@ -348,6 +349,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
plan: true,
theme: true,
brandColor: true,
darkBrandColor: true,
},
});
if (user) {
@ -365,6 +367,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
name: eventType.team?.name || eventType.users[0]?.name || null,
theme: (!eventType.team?.name && eventType.users[0]?.theme) || null,
brandColor: eventType.team ? null : eventType.users[0].brandColor,
darkBrandColor: eventType.team ? null : eventType.users[0].darkBrandColor,
};
return {

View file

@ -48,6 +48,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
hideBranding: true,
plan: true,
brandColor: true,
darkBrandColor: true,
},
},
title: true,
@ -105,6 +106,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
theme: null,
weekStart: "Sunday",
brandColor: "" /* TODO: Add a way to set a brand color for Teams */,
darkBrandColor: "" /* TODO: Add a way to set a brand color for Teams */,
},
date: dateParam,
eventType: eventTypeObject,

View file

@ -102,6 +102,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
image: eventTypeObject.team?.logo || null,
theme: null /* Teams don't have a theme, and `BookingPage` uses it */,
brandColor: null /* Teams don't have a brandColor, and `BookingPage` uses it */,
darkBrandColor: null /* Teams don't have a darkBrandColor, and `BookingPage` uses it */,
},
eventType: eventTypeObject,
booking,

View file

@ -513,6 +513,8 @@
"first_day_of_week": "First Day of Week",
"single_theme": "Single Theme",
"brand_color": "Brand Color",
"light_brand_color": "Brand Color (Light Theme)",
"dark_brand_color": "Brand Color (Dark Theme)",
"file_not_named": "File is not named [idOrSlug]/[user]",
"create_team": "Create Team",
"name": "Name",

View file

@ -45,6 +45,7 @@ async function getUserFromSession({
twoFactorEnabled: true,
identityProvider: true,
brandColor: true,
darkBrandColor: true,
plan: true,
away: true,
credentials: {

View file

@ -83,6 +83,7 @@ const loggedInViewerRouter = createProtectedRouter()
twoFactorEnabled: user.twoFactorEnabled,
identityProvider: user.identityProvider,
brandColor: user.brandColor,
darkBrandColor: user.darkBrandColor,
plan: user.plan,
away: user.away,
};
@ -616,6 +617,7 @@ const loggedInViewerRouter = createProtectedRouter()
weekStart: z.string().optional(),
hideBranding: z.boolean().optional(),
brandColor: z.string().optional(),
darkBrandColor: z.string().optional(),
theme: z.string().optional().nullable(),
completedOnboarding: z.boolean().optional(),
locale: z.string().optional(),

View file

@ -5,6 +5,8 @@
:root {
--brand-color: #292929;
--brand-text-color: #ffffff;
--brand-color-dark-mode: #fafafa;
--brand-text-color-dark-mode: #292929;
}
/* PhoneInput dark-mode overrides (it would add a lot of boilerplate to do this in JavaScript) */

View file

@ -14,6 +14,8 @@ module.exports = {
/* your primary brand color */
brand: "var(--brand-color)",
brandcontrast: "var(--brand-text-color)",
darkmodebrand: "var(--brand-color-dark-mode)",
darkmodebrandcontrast: "var(--brand-text-color-dark-mode)",
black: "#111111",
gray: {
50: "#F8F8F8",

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "darkBrandColor" TEXT NOT NULL DEFAULT E'#fafafa';

View file

@ -142,6 +142,7 @@ model User {
Schedule Schedule[]
webhooks Webhook[]
brandColor String @default("#292929")
darkBrandColor String @default("#fafafa")
// the location where the events will end up
destinationCalendar DestinationCalendar?
away Boolean @default(false)

View file

@ -65,7 +65,7 @@ export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonPr
color === "primary" &&
(disabled
? "border border-transparent bg-gray-400 text-white"
: "border border-transparent dark:text-brandcontrast text-brandcontrast bg-brand dark:bg-brand hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
: "border border-transparent dark:text-darkmodebrandcontrast text-brandcontrast bg-brand dark:bg-darkmodebrand hover:bg-opacity-90 hover:shadow-md focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-neutral-900"),
color === "secondary" &&
(disabled
? "border border-gray-200 text-gray-400 bg-white"