From 5ed81e4be99a8b7f3c68a3ba70358e4cbe5cc613 Mon Sep 17 00:00:00 2001 From: Razboy20 Date: Tue, 19 Mar 2024 18:54:11 -0500 Subject: [PATCH] feat: course color generation (#179) * feat: course color generation * feat: add proper TS for hex colors * refactor: fix oklab and improve contrast ratios * fix: update HexColor type * refactor: update color switch point * refactor: color-related functions and types * fix: imports and TS issues * fix: imports and TS issues * chore: add no-restricted-syntax ForInStatement * chore(docs): add jsdoc --------- Co-authored-by: doprz <52579214+doprz@users.noreply.github.com> --- .eslintrc | 215 --------------- src/debug/index.tsx | 2 + src/pages/background/lib/addCourse.ts | 2 + src/pages/background/lib/deleteSchedule.ts | 4 +- src/pages/background/lib/renameSchedule.ts | 2 +- src/shared/types/Color.ts | 27 ++ src/shared/types/Course.ts | 3 +- src/shared/types/ThemeColors.ts | 78 ++++++ src/shared/util/colors.ts | 254 +++++++++++++++--- src/shared/util/storybook.ts | 9 +- src/shared/util/tests/colors.test.ts | 21 -- src/shared/util/tests/themeColors.test.ts | 32 +-- src/shared/util/themeColors.ts | 63 +---- src/shared/util/updateBadgeText.ts | 2 +- .../CourseCellColorPicker.stories.tsx | 2 +- .../components/calendar/Calendar/Calendar.tsx | 4 +- .../CalendarCourseCell/CalendarCourseCell.tsx | 13 +- src/views/components/calendar/utils.ts | 2 +- src/views/components/common/Button/Button.tsx | 2 +- src/views/components/common/Dialog/Dialog.tsx | 5 +- src/views/components/common/List/List.tsx | 28 +- src/views/components/common/LogoIcon.tsx | 17 ++ .../PopupCourseBlock/PopupCourseBlock.tsx | 4 +- .../ScheduleListItem/ScheduleListItem.tsx | 5 +- .../CourseCatalogInjectedPopup.tsx | 3 + .../GradeDistribution.tsx | 2 +- src/views/hooks/useFlattenedCourseSchedule.ts | 33 ++- src/views/hooks/useSchedules.ts | 9 + src/views/index.tsx | 1 - unocss.config.ts | 2 +- 30 files changed, 424 insertions(+), 422 deletions(-) delete mode 100644 .eslintrc create mode 100644 src/shared/types/Color.ts create mode 100644 src/shared/types/ThemeColors.ts delete mode 100644 src/shared/util/tests/colors.test.ts diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 9c185f84..00000000 --- a/.eslintrc +++ /dev/null @@ -1,215 +0,0 @@ -{ - "root": true, - "env": { - "browser": true, - "es6": true, - "node": true, - "webextensions": true - }, - "ignorePatterns": [ - "*.html", - "tsconfig.json" - ], - "extends": [ - "eslint:recommended", - "plugin:react/recommended", - "plugin:react-hooks/recommended", - "plugin:storybook/recommended", - "airbnb-base", - "airbnb/rules/react", - "airbnb-typescript", - "@unocss", - "prettier", - ], - "plugins": [ - "import", - "jsdoc", - "react-prefer-function-component" - ], - "globals": { - "Atomics": "readonly", - "SharedArrayBuffer": "readonly", - "debugger": true, - "browser": true, - "context": true, - "JSX": true - }, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": "./tsconfig.json", - "ecmaVersion": 2022, - "sourceType": "module", - "ecmaFeatures": { - "jsx": true, - "modules": true, - "experimentalObjectRestSpread": true - } - }, - "settings": { - "react": { - "version": "detect" - }, - "jsdoc": { - "mode": "typescript" - }, - "import/parsers": { - "@typescript-eslint/parser": [ - ".ts", - ".tsx" - ] - }, - "import/resolver": { - "typescript": { - "alwaysTryTypes": true, - "project": "./tsconfig.json" - } - } - }, - "rules": { - "prefer-const": [ - "off", - { - "destructuring": "any", - "ignoreReadBeforeAssign": false - } - ], - "no-inner-declarations": "off", - "sort-imports": "off", - "no-case-declarations": "off", - "no-unreachable": "warn", - "no-constant-condition": "error", - "space-before-function-paren": "off", - "no-undef": "off", - "no-return-await": "off", - "@typescript-eslint/return-await": "off", - "@typescript-eslint/no-shadow": [ - "off" - ], - "@typescript-eslint/no-use-before-define": [ - "off" - ], - "class-methods-use-this": "off", - "react-hooks/exhaustive-deps": "warn", - "@typescript-eslint/lines-between-class-members": "off", - "no-param-reassign": [ - "error", - { - "props": false - } - ], - "no-console": "off", - "consistent-return": "off", - "react/destructuring-assignment": "off", - "import/prefer-default-export": "off", - "no-promise-executor-return": "off", - "import/no-cycle": "off", - "import/no-extraneous-dependencies": "off", - "react/jsx-props-no-spreading": "off", - "keyword-spacing": [ - "error", - { - "before": true, - "after": true - } - ], - "no-continue": "off", - "space-before-blocks": [ - "error", - { - "functions": "always", - "keywords": "always", - "classes": "always" - } - ], - "react/jsx-filename-extension": [ - 1, - { - "extensions": [ - ".tsx" - ] - } - ], - "react/no-deprecated": "warn", - "react/prop-types": "off", - "react-prefer-function-component/react-prefer-function-component": [ - "warn", - { - "allowComponentDidCatch": false - } - ], - "react/function-component-definition": "off", - "react/button-has-type": "off", - "jsdoc/require-param-type": "off", - "jsdoc/require-returns-type": "off", - "jsdoc/newline-after-description": "off", - "react/require-default-props": "off", - "jsdoc/require-jsdoc": [ - "warn", - { - "enableFixer": false, - "publicOnly": true, - "checkConstructors": false, - "require": { - "ArrowFunctionExpression": true, - "ClassDeclaration": true, - "ClassExpression": true, - "FunctionExpression": true - }, - "contexts": [ - "MethodDefinition:not([key.name=\"componentDidMount\"]):not([key.name=\"render\"])", - "ArrowFunctionExpression", - "ClassDeclaration", - "ClassExpression", - "ClassProperty:not([key.name=\"state\"]):not([key.name=\"componentDidMount\"])", - "FunctionDeclaration", - "FunctionExpression", - "TSDeclareFunction", - "TSEnumDeclaration", - "TSInterfaceDeclaration", - "TSMethodSignature", - "TSModuleDeclaration", - "TSTypeAliasDeclaration" - ] - } - ], - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/no-unused-vars": "warn", - "@typescript-eslint/naming-convention": "off", - "@typescript-eslint/space-before-function-paren": "off", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-empty-interface": "warn", - "import/no-restricted-paths": [ - "error", - { - "zones": [ - { - "target": "./src/background", - "from": "./src/views", - "message": "You cannot import into the `background` directory from the `views` directory (i.e. content script files) because it will break the build!" - }, - { - "target": "./src/views", - "from": "./src/background", - "message": "You cannot import into the `views` directory from the `background` directory (i.e. background script files) because it will break the build!" - }, - { - "target": "./src/shared", - "from": "./", - "except": [ - "./src/shared", - "./node_modules" - ], - "message": "You cannot import into `shared` from an external directory." - } - ] - } - ], - "import/extensions": "off", - "no-restricted-syntax": [ - "error", - "ForInStatement", - "LabeledStatement", - "WithStatement" - ] - } -} diff --git a/src/debug/index.tsx b/src/debug/index.tsx index 9ba5b25c..e39acc93 100644 --- a/src/debug/index.tsx +++ b/src/debug/index.tsx @@ -29,6 +29,8 @@ function JSONEditor(props: JSONEditorProps) { setIsEditing(false); } catch (e) { console.error(e); + + // eslint-disable-next-line no-alert alert('Invalid JSON'); } }; diff --git a/src/pages/background/lib/addCourse.ts b/src/pages/background/lib/addCourse.ts index c003b277..2594b288 100644 --- a/src/pages/background/lib/addCourse.ts +++ b/src/pages/background/lib/addCourse.ts @@ -1,5 +1,6 @@ import { UserScheduleStore } from '@shared/storage/UserScheduleStore'; import type { Course } from '@shared/types/Course'; +import { getUnusedColor } from '@shared/util/colors'; /** * Adds a course to a user's schedule. @@ -15,6 +16,7 @@ export default async function addCourse(scheduleId: string, course: Course): Pro throw new Error('Schedule not found'); } + course.colors = getUnusedColor(activeSchedule, course); activeSchedule.courses.push(course); activeSchedule.updatedAt = Date.now(); diff --git a/src/pages/background/lib/deleteSchedule.ts b/src/pages/background/lib/deleteSchedule.ts index c7f2eaf0..51c6453c 100644 --- a/src/pages/background/lib/deleteSchedule.ts +++ b/src/pages/background/lib/deleteSchedule.ts @@ -14,10 +14,10 @@ export default async function deleteSchedule(scheduleId: string): Promise schedule.id === scheduleId); if (scheduleIndex === -1) { - return `Schedule ${scheduleId} does not exist`; + throw new Error(`Schedule ${scheduleId} does not exist`); } if (scheduleIndex === activeIndex) { - return 'Cannot delete active schedule'; + throw new Error('Cannot delete active schedule'); } schedules.splice(scheduleIndex, 1); diff --git a/src/pages/background/lib/renameSchedule.ts b/src/pages/background/lib/renameSchedule.ts index cfd38db5..9f2e545e 100644 --- a/src/pages/background/lib/renameSchedule.ts +++ b/src/pages/background/lib/renameSchedule.ts @@ -17,7 +17,7 @@ export default async function renameSchedule(scheduleId: string, newName: string // } schedules[scheduleIndex].name = newName; - schedules[scheduleIndex].updatedAt = Date.now(); + // schedules[scheduleIndex].updatedAt = Date.now(); await UserScheduleStore.set('schedules', schedules); return undefined; diff --git a/src/shared/types/Color.ts b/src/shared/types/Color.ts new file mode 100644 index 00000000..c9a72585 --- /dev/null +++ b/src/shared/types/Color.ts @@ -0,0 +1,27 @@ +/** + * Represents a hexadecimal color value. + */ +export type HexColor = `#${string}`; + +/** + * Checks if a string is a valid hexadecimal color value. + * + * @param color - The color string to check. + * @returns A boolean indicating if the color is a valid hexadecimal color value. + */ +export const isHexColor = (color: string): color is HexColor => /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color); + +/** + * Represents an RGB color value. + */ +export type RGB = [r: number, g: number, b: number]; + +/** + * Represents a linear sRGB color value. + */ +export type sRGB = [r: number, g: number, b: number]; + +/** + * Represents a Lab color value. + */ +export type Lab = [l: number, a: number, b: number]; diff --git a/src/shared/types/Course.ts b/src/shared/types/Course.ts index 492f5cf4..8ede8d4b 100644 --- a/src/shared/types/Course.ts +++ b/src/shared/types/Course.ts @@ -1,9 +1,10 @@ -import { type CourseColors, getCourseColors } from '@shared/util/colors'; +import { getCourseColors } from '@shared/util/colors'; import type { Serialized } from 'chrome-extension-toolkit'; import type { CourseMeeting } from './CourseMeeting'; import { CourseSchedule } from './CourseSchedule'; import Instructor from './Instructor'; +import type { CourseColors } from './ThemeColors'; /** * Whether the class is taught online, in person, or a hybrid of the two diff --git a/src/shared/types/ThemeColors.ts b/src/shared/types/ThemeColors.ts new file mode 100644 index 00000000..394a74c9 --- /dev/null +++ b/src/shared/types/ThemeColors.ts @@ -0,0 +1,78 @@ +import type { theme } from 'unocss/preset-mini'; + +import type { HexColor } from './Color'; + +export const colors = { + ut: { + burntorange: '#BF5700', + black: '#333F48', + orange: '#F8971F', + yellow: '#FFD600', + lightgreen: '#A6CD57', + green: '#579D42', + teal: '#00A9B7', + blue: '#005F86', + gray: '#9CADB7', + offwhite: '#D6D2C4', + concrete: '#95A5A6', + red: '#B91C1C', // Not sure if this should be here, but it's used for remove course, and add course is ut-green + }, + theme: { + red: '#AF2E2D', + black: '#1A2024', + }, +} as const satisfies Record>; + +export const extendedColors = { + ...colors, + gradeDistribution: { + a: '#22C55E', + aminus: '#A3E635', + bplus: '#84CC16', + b: '#FDE047', + bminus: '#FACC15', + cplus: '#F59E0B', + c: '#FB923C', + cminus: '#F97316', + dplus: '#EF4444', + d: '#DC2626', + dminus: '#B91C1C', + f: '#B91C1C', + }, +} as const; + +type NestedKeys = { + [K in keyof T]: T[K] extends Record ? `${string & K}-${string & keyof T[K]}` : never; +}[keyof T]; + +/** + * A union of all colors in the theme + */ +export type ThemeColor = NestedKeys; + +/** + * Represents a Tailwind colorway: a colorway is a key in the theme.colors object that has an object as its value. + */ +export type TWColorway = { + [K in keyof typeof theme.colors]: (typeof theme.colors)[K] extends Record ? K : never; +}[keyof typeof theme.colors]; + +/** + * Represents the colors for a course. + */ +export interface CourseColors { + primaryColor: HexColor; + secondaryColor: HexColor; +} + +/** + * Adjusted colorway indexes for better *quality* + */ +export const colorwayIndexes = { + yellow: 300, + amber: 400, + emerald: 400, + lime: 400, + orange: 400, + sky: 600, +} as const satisfies Record; diff --git a/src/shared/util/colors.ts b/src/shared/util/colors.ts index a18a9d7b..177e33cd 100644 --- a/src/shared/util/colors.ts +++ b/src/shared/util/colors.ts @@ -1,58 +1,232 @@ +import type { Serialized } from 'chrome-extension-toolkit'; import { theme } from 'unocss/preset-mini'; -/** - * Represents the colors for a course. - */ -export interface CourseColors { - primaryColor: string; - secondaryColor: string; -} +import type { HexColor, Lab, RGB, sRGB } from '../types/Color'; +import { isHexColor } from '../types/Color'; +import type { Course } from '../types/Course'; +import type { CourseColors, TWColorway } from '../types/ThemeColors'; +import { colorwayIndexes } from '../types/ThemeColors'; +import type { UserSchedule } from '../types/UserSchedule'; /** - * Calculates the luminance of a given hexadecimal color. + * Converts a hexadecimal color value to RGB format. (adapted from https://stackoverflow.com/a/5624139/8022866) * * @param hex - The hexadecimal color value. - * @returns The luminance value between 0 and 1. + * @returns An array containing the RGB values. */ -export function getLuminance(hex: string): number { - let r = parseInt(hex.substring(1, 3), 16); - let g = parseInt(hex.substring(3, 5), 16); - let b = parseInt(hex.substring(5, 7), 16); +export function hexToRGB(hex: HexColor): RGB { + // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") + let shorthandRegex: RegExp = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + const parsedHex: string = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b); - [r, g, b] = [r, g, b].map(color => { - let c = color / 255; - - c = c > 0.03928 ? ((c + 0.055) / 1.055) ** 2.4 : (c /= 12.92); - - return c; - }); - - return 0.2126 * r + 0.7152 * g + 0.0722 * b; + let result: RegExpExecArray = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(parsedHex); + return result ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)] : null; } -// calculates contrast ratio between two hex strings -function contrastRatioPair(hex1: string, hex2: string) { - const lum1 = getLuminance(hex1); - const lum2 = getLuminance(hex2); +export const useableColorways = Object.keys(theme.colors) + // check that the color is a colorway (is an object) + .filter(color => typeof theme.colors[color] === 'object') + .slice(0, 17) as TWColorway[]; - return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); +/** + * Generate a Tailwind classname for the font color based on the background color + * @param bgColor the hex color of the background + */ +export function pickFontColor(bgColor: HexColor): 'text-white' | 'text-black' | 'text-theme-black' { + const coefficients = [0.2126729, 0.7151522, 0.072175]; + + const flipYs = 0.342; // based on APCA™ 0.98G middle contrast BG color + + const trc = 2.4; // 2.4 exponent for emulating actual monitor perception + let Ys = hexToRGB(bgColor).reduce((acc, c, i) => acc + (c / 255.0) ** trc * coefficients[i], 0); + + if (Ys < flipYs) { + return 'text-white'; + } + + return Ys < 0.365 ? 'text-black' : 'text-theme-black'; } /** - * Generate a tailwind classname for the font color based on the background color - * @param bgColor the tailwind classname for background ex. "bg-emerald-500" + * Get primary and secondary colors from a Tailwind colorway + * @param colorway the Tailwind colorway ex. "emerald" */ -export function pickFontColor(bgColor: string): 'text-white' | 'text-black' { - return contrastRatioPair(bgColor, '#606060') > contrastRatioPair(bgColor, '#ffffff') ? 'text-black' : 'text-white'; -} +export function getCourseColors(colorway: TWColorway, index?: number, offset: number = 300): CourseColors { + if (index === undefined) { + // eslint-disable-next-line no-param-reassign + index = colorway in colorwayIndexes ? colorwayIndexes[colorway] : 500; + } -/** - * Get primary and secondary colors from a tailwind colorway - * @param colorway the tailwind colorway ex. "emerald" - */ -export function getCourseColors(colorway: keyof typeof theme.colors, index = 600, offset = 200): CourseColors { return { - primaryColor: theme.colors[colorway][index] as string, - secondaryColor: theme.colors[colorway][index + offset] as string, - }; + primaryColor: theme.colors[colorway][index], + secondaryColor: theme.colors[colorway][index + offset], + } satisfies CourseColors; +} + +/** + * Get the Tailwind colorway from a given color. + * + * @param color - The hexadecimal color value. + * @returns The Tailwind colorway. + */ +export function getColorwayFromColor(color: HexColor): TWColorway { + for (const colorway of useableColorways) { + if (Object.values(theme.colors[colorway]).includes(color)) { + return colorway as TWColorway; + } + } + + // not a direct match, get the closest color + let closestColor = ''; + let closestDistance = Infinity; + + for (const colorway of useableColorways) { + for (const [shade, shadeColor] of Object.entries(theme.colors[colorway])) { + // type guard + if (!isHexColor(shadeColor)) { + continue; + } + + const distance = oklabDistance(rgbToOKlab(hexToRGB(shadeColor)), rgbToOKlab(hexToRGB(color))); + if (distance < closestDistance) { + closestDistance = distance; + closestColor = shade; + } + } + } + + // type guard + if (!isHexColor(closestColor)) { + throw new Error("closestColor isn't a valid hex color"); + } + + return getColorwayFromColor(closestColor); +} + +/** + * Get next unused color in a tailwind colorway for a given schedule + * @param schedule the schedule which the course is in + * @param course the course to get the color for + */ +export function getUnusedColor( + schedule: Serialized, + course: Course, + index?: number, + offset?: number +): CourseColors { + // strategy: First, check if any of the course's in schedule have the same department as the current course, + // if so, use a colorway near the color of that course if possible. + // Otherwise, find a colorway that is not used in the schedule and at least two hues away from any other color in the schedule. + + // wrapping helper functions + function getPreviousColorway(colorway: TWColorway): TWColorway { + const colorwayIndex = useableColorways.indexOf(colorway); + return useableColorways[(colorwayIndex - 1 + useableColorways.length) % useableColorways.length] as TWColorway; + } + + function getNextColorway(colorway: TWColorway): TWColorway { + const colorwayIndex = useableColorways.indexOf(colorway); + return useableColorways[(colorwayIndex + 1) % useableColorways.length] as TWColorway; + } + + const scheduleCourses = schedule.courses.map(c => ({ + ...c, + colorway: getColorwayFromColor(c.colors.primaryColor), + })); + const usedColorways = new Set(scheduleCourses.map(c => c.colorway)); + const availableColorways = new Set(useableColorways.filter(c => !usedColorways.has(c))); + + if (availableColorways.size > 0) { + let sameDepartment = scheduleCourses.filter(c => c.department === course.department); + + sameDepartment.sort((a, b) => { + const aIndex = useableColorways.indexOf(a.colorway); + const bIndex = useableColorways.indexOf(b.colorway); + + return aIndex - bIndex; + }); + + if (sameDepartment.length > 0) { + // check to see if any adjacent colorways are available + const centerCourse = sameDepartment[Math.floor(Math.random() * sameDepartment.length)]; + let nextColorway = getNextColorway(centerCourse.colorway); + let prevColorway = getPreviousColorway(centerCourse.colorway); + + // eslint-disable-next-line no-constant-condition + while (true) { + if (availableColorways.has(nextColorway)) { + return getCourseColors(nextColorway, index, offset); + } + if (availableColorways.has(prevColorway)) { + return getCourseColors(prevColorway, index, offset); + } + + nextColorway = getNextColorway(nextColorway); + prevColorway = getPreviousColorway(prevColorway); + } + } + + const shortenedColorways = new Set(availableColorways); + // no courses in the same department, restrict colorways to those which are at least 2 indexes away from any used colors + for (const colorway of usedColorways) { + shortenedColorways.delete(getPreviousColorway(colorway)); + shortenedColorways.delete(getNextColorway(colorway)); + } + + if (shortenedColorways.size > 0) { + // TODO: make this go by 3's to leave future spaces open + const randomColorway = Array.from(shortenedColorways)[Math.floor(Math.random() * shortenedColorways.size)]; + return getCourseColors(randomColorway, index, offset); + } + // no colorways are at least 2 indexes away from any used colors, just get a random colorway + const randomColorway = Array.from(availableColorways)[Math.floor(Math.random() * availableColorways.size)]; + return getCourseColors(randomColorway, index, offset); + } + // TODO: get just a random color idk + return getCourseColors('emerald', index, offset); +} + +// OKLab helper functions (https://github.com/bottosson/bottosson.github.io/blob/master/misc/colorpicker/colorconversion.js) +function srgbTransferFunction(a: number): number { + return a <= 0.0031308 ? 12.92 * a : 1.055 * a ** 0.4166666666666667 - 0.055; +} + +function srgbTransferFunctionInv(a: number): number { + return a > 0.04045 ? ((a + 0.055) / 1.055) ** 2.4 : a / 12.92; +} + +function rgbToSrgb(rgb: RGB): sRGB { + return rgb.map(c => srgbTransferFunctionInv(c / 255)) as sRGB; +} + +/** + * Convert an RGB color to the OKLab color space + * @param rgb the RGB color + * @returns the color in the OKLab color space + */ +function srgbToOKlab([r, g, b]: sRGB): Lab { + let l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + let m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + let s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + let lc = Math.cbrt(l); + let mc = Math.cbrt(m); + let sc = Math.cbrt(s); + + return [ + 0.2104542553 * lc + 0.793617785 * mc - 0.0040720468 * sc, + 1.9779984951 * lc - 2.428592205 * mc + 0.4505937099 * sc, + 0.0259040371 * lc + 0.7827717662 * mc - 0.808675766 * sc, + ]; +} + +function rgbToOKlab(rgb: RGB): Lab { + return srgbToOKlab(rgbToSrgb(rgb)); +} + +/** + * Calculate the distance between two colors in the OKLab color space + */ +function oklabDistance([l1, a1, b1]: Lab, [l2, a2, b2]: Lab): number { + return Math.sqrt((l2 - l1) ** 2 + (a2 - a1) ** 2 + (b2 - b1) ** 2); } diff --git a/src/shared/util/storybook.ts b/src/shared/util/storybook.ts index fb7986a6..b403879a 100644 --- a/src/shared/util/storybook.ts +++ b/src/shared/util/storybook.ts @@ -1,8 +1,3 @@ -import { getCourseColors } from '@shared/util/colors'; -import { theme } from 'unocss/preset-mini'; +import { getCourseColors, useableColorways } from '@shared/util/colors'; -export const tailwindColorways = Object.keys(theme.colors) - // check that the color is a colorway (is an object) - .filter(color => typeof theme.colors[color] === 'object') - .slice(0, 17) - .map(color => getCourseColors(color as keyof typeof theme.colors)); +export const tailwindColorways = useableColorways.map(color => getCourseColors(color)); diff --git a/src/shared/util/tests/colors.test.ts b/src/shared/util/tests/colors.test.ts deleted file mode 100644 index aeaa3712..00000000 --- a/src/shared/util/tests/colors.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getLuminance } from '@shared/util/colors'; -import { describe, expect, it } from 'vitest'; - -describe('getLuminance', () => { - it('should return the correct luminance value for a given hex color', () => { - // Test case 1: Hex color #FFFFFF (white) - expect(getLuminance('#FFFFFF')).toBeCloseTo(1); - - // Test case 2: Hex color #000000 (black) - expect(getLuminance('#000000')).toBeCloseTo(0); - - // Test case 3: Hex color #FF0000 (red) - expect(getLuminance('#FF0000')).toBeCloseTo(0.2126); - - // Test case 4: Hex color #00FF00 (green) - expect(getLuminance('#00FF00')).toBeCloseTo(0.7152); - - // Test case 5: Hex color #0000FF (blue) - expect(getLuminance('#0000FF')).toBeCloseTo(0.0722); - }); -}); diff --git a/src/shared/util/tests/themeColors.test.ts b/src/shared/util/tests/themeColors.test.ts index 2e95fa7b..2dd34653 100644 --- a/src/shared/util/tests/themeColors.test.ts +++ b/src/shared/util/tests/themeColors.test.ts @@ -1,36 +1,6 @@ -import { getThemeColorHexByName, getThemeColorRgbByName, hexToRgb } from '@shared/util/themeColors'; +import { getThemeColorHexByName, getThemeColorRgbByName } from '@shared/util/themeColors'; import { describe, expect, it } from 'vitest'; -describe('hexToRgb', () => { - it('should convert hex color to RGB', () => { - expect(hexToRgb('#BF5700')).toEqual([191, 87, 0]); - expect(hexToRgb('#333F48')).toEqual([51, 63, 72]); - expect(hexToRgb('#f8971f')).toEqual([248, 151, 31]); - expect(hexToRgb('#ffd600')).toEqual([255, 214, 0]); - expect(hexToRgb('#a6cd57')).toEqual([166, 205, 87]); - expect(hexToRgb('#579d42')).toEqual([87, 157, 66]); - expect(hexToRgb('#00a9b7')).toEqual([0, 169, 183]); - expect(hexToRgb('#005f86')).toEqual([0, 95, 134]); - expect(hexToRgb('#9cadb7')).toEqual([156, 173, 183]); - expect(hexToRgb('#d6d2c4')).toEqual([214, 210, 196]); - expect(hexToRgb('#95a5a6')).toEqual([149, 165, 166]); - expect(hexToRgb('#B91C1C')).toEqual([185, 28, 28]); - expect(hexToRgb('#af2e2d')).toEqual([175, 46, 45]); - expect(hexToRgb('#1a2024')).toEqual([26, 32, 36]); - expect(hexToRgb('#22c55e')).toEqual([34, 197, 94]); - expect(hexToRgb('#a3e635')).toEqual([163, 230, 53]); - expect(hexToRgb('#84CC16')).toEqual([132, 204, 22]); - expect(hexToRgb('#FDE047')).toEqual([253, 224, 71]); - expect(hexToRgb('#FACC15')).toEqual([250, 204, 21]); - expect(hexToRgb('#F59E0B')).toEqual([245, 158, 11]); - expect(hexToRgb('#FB923C')).toEqual([251, 146, 60]); - expect(hexToRgb('#F97316')).toEqual([249, 115, 22]); - expect(hexToRgb('#EA580C')).toEqual([234, 88, 12]); - expect(hexToRgb('#DC2626')).toEqual([220, 38, 38]); - expect(hexToRgb('#B91C1C')).toEqual([185, 28, 28]); - }); -}); - describe('getThemeColorHexByName', () => { it('should return the hex color value by name', () => { expect(getThemeColorHexByName('ut-burntorange')).toEqual('#BF5700'); diff --git a/src/shared/util/themeColors.ts b/src/shared/util/themeColors.ts index fe02968d..ea6e5803 100644 --- a/src/shared/util/themeColors.ts +++ b/src/shared/util/themeColors.ts @@ -1,50 +1,7 @@ -export const colors = { - ut: { - burntorange: '#BF5700', - black: '#333F48', - orange: '#F8971F', - yellow: '#FFD600', - lightgreen: '#A6CD57', - green: '#579D42', - teal: '#00A9B7', - blue: '#005F86', - gray: '#9CADB7', - offwhite: '#D6D2C4', - concrete: '#95A5A6', - red: '#B91C1C', // Not sure if this should be here, but it's used for remove course, and add course is ut-green - }, - theme: { - red: '#AF2E2D', - black: '#1A2024', - }, -} as const satisfies Record>; - -export const extendedColors = { - ...colors, - gradeDistribution: { - a: '#22C55E', - aminus: '#A3E635', - bplus: '#84CC16', - b: '#FDE047', - bminus: '#FACC15', - cplus: '#F59E0B', - c: '#FB923C', - cminus: '#F97316', - dplus: '#EF4444', - d: '#DC2626', - dminus: '#B91C1C', - f: '#B91C1C', - }, -} as const; - -type NestedKeys = { - [K in keyof T]: T[K] extends Record ? `${string & K}-${string & keyof T[K]}` : never; -}[keyof T]; - -/** - * A union of all colors in the theme - */ -export type ThemeColor = NestedKeys; +import type { HexColor } from '../types/Color'; +import type { ThemeColor } from '../types/ThemeColors'; +import { colors } from '../types/ThemeColors'; +import { hexToRGB } from './colors'; /** * Flattened colors object. @@ -60,21 +17,13 @@ export const colorsFlattened = Object.entries(colors).reduce( {} as Record ); -/** - * Converts a hexadecimal color code to an RGB color array. - * @param hex The hexadecimal color code to convert. - * @returns An array representing the RGB color values. - */ -export const hexToRgb = (hex: string) => - hex.match(/[0-9a-f]{2}/gi).map(partialHex => parseInt(partialHex, 16)) as [number, number, number]; - /** * Represents the flattened RGB values of the colors. * @type {Record>} */ const colorsFlattenedRgb = Object.fromEntries( - Object.entries(colorsFlattened).map(([name, hex]) => [name, hexToRgb(hex)]) -) as Record>; + Object.entries(colorsFlattened).map(([name, hex]) => [name, hexToRGB(hex as HexColor)]) +) as Record>; /** * Retrieves the hexadecimal color value by name from the theme. diff --git a/src/shared/util/updateBadgeText.ts b/src/shared/util/updateBadgeText.ts index ef695eb3..a28c086f 100644 --- a/src/shared/util/updateBadgeText.ts +++ b/src/shared/util/updateBadgeText.ts @@ -1,4 +1,4 @@ -import { colors } from './themeColors'; +import { colors } from '../types/ThemeColors'; import { MILLISECOND } from './time'; /** How long should we flash the badge when it changes value */ diff --git a/src/stories/components/calendar/CourseCellColorPicker.stories.tsx b/src/stories/components/calendar/CourseCellColorPicker.stories.tsx index 11e9129f..3345cc44 100644 --- a/src/stories/components/calendar/CourseCellColorPicker.stories.tsx +++ b/src/stories/components/calendar/CourseCellColorPicker.stories.tsx @@ -1,4 +1,4 @@ -import type { ThemeColor } from '@shared/util/themeColors'; +import type { ThemeColor } from '@shared/types/ThemeColors'; import type { Meta, StoryObj } from '@storybook/react'; import CourseCellColorPicker from '@views/components/calendar/CalendarCourseCellColorPicker/CourseCellColorPicker'; import React, { useState } from 'react'; diff --git a/src/views/components/calendar/Calendar/Calendar.tsx b/src/views/components/calendar/Calendar/Calendar.tsx index d49fd18b..67c284cd 100644 --- a/src/views/components/calendar/Calendar/Calendar.tsx +++ b/src/views/components/calendar/Calendar/Calendar.tsx @@ -76,8 +76,8 @@ export default function Calendar(): JSX.Element { )} -
-
+
+
diff --git a/src/views/components/calendar/CalendarCourseCell/CalendarCourseCell.tsx b/src/views/components/calendar/CalendarCourseCell/CalendarCourseCell.tsx index b1e24e60..e1521192 100644 --- a/src/views/components/calendar/CalendarCourseCell/CalendarCourseCell.tsx +++ b/src/views/components/calendar/CalendarCourseCell/CalendarCourseCell.tsx @@ -1,6 +1,6 @@ import type { StatusType } from '@shared/types/Course'; import { Status } from '@shared/types/Course'; -import type { CourseColors } from '@shared/util/colors'; +import type { CourseColors } from '@shared/types/ThemeColors'; import { pickFontColor } from '@shared/util/colors'; import Text from '@views/components/common/Text/Text'; import clsx from 'clsx'; @@ -51,7 +51,7 @@ export default function CalendarCourseCell({ rightIcon = ; } - // whiteText based on secondaryColor + // text-white or text-black based on secondaryColor const fontColor = pickFontColor(colors.primaryColor); return ( @@ -73,14 +73,19 @@ export default function CalendarCourseCell({ > {courseDeptAndInstr} - {timeAndLocation && {timeAndLocation}} + {timeAndLocation && ( + + {timeAndLocation} + + )}
{rightIcon && (
{ console.log(icsDays); // Assuming course has date started and ended, adapt as necessary - const year = new Date().getFullYear(); // Example year, adapt accordingly + // const year = new Date().getFullYear(); // Example year, adapt accordingly // Example event date, adapt startDate according to your needs const startDate = `20240101T${formattedStartTime}`; const endDate = `20240101T${formattedEndTime}`; diff --git a/src/views/components/common/Button/Button.tsx b/src/views/components/common/Button/Button.tsx index ddab9758..716ebac2 100644 --- a/src/views/components/common/Button/Button.tsx +++ b/src/views/components/common/Button/Button.tsx @@ -1,4 +1,4 @@ -import type { ThemeColor } from '@shared/util/themeColors'; +import type { ThemeColor } from '@shared/types/ThemeColors'; import { getThemeColorHexByName, getThemeColorRgbByName } from '@shared/util/themeColors'; import Text from '@views/components/common/Text/Text'; import clsx from 'clsx'; diff --git a/src/views/components/common/Dialog/Dialog.tsx b/src/views/components/common/Dialog/Dialog.tsx index c3ffee9d..abb19d4f 100644 --- a/src/views/components/common/Dialog/Dialog.tsx +++ b/src/views/components/common/Dialog/Dialog.tsx @@ -6,12 +6,15 @@ import React, { Fragment } from 'react'; import ExtensionRoot from '../ExtensionRoot/ExtensionRoot'; -export interface _DialogProps { +interface _DialogProps { className?: string; title?: JSX.Element; description?: JSX.Element; } +/** + * Props for the Dialog component. + */ export type DialogProps = _DialogProps & Omit, 'children'>; /** diff --git a/src/views/components/common/List/List.tsx b/src/views/components/common/List/List.tsx index e8c7b410..96631760 100644 --- a/src/views/components/common/List/List.tsx +++ b/src/views/components/common/List/List.tsx @@ -69,21 +69,21 @@ function Item(props: { * @example * */ -function List(props: ListProps): JSX.Element { - const [items, setItems] = useState(wrap(props.draggables, props.itemKey)); +function List({ draggables, itemKey, children, onReordered, gap }: ListProps): JSX.Element { + const [items, setItems] = useState(wrap(draggables, itemKey)); - const transformFunction = props.children; + const transformFunction = children; useEffect(() => { // check if the draggables content has *actually* changed if ( - props.draggables.length === items.length && - props.draggables.every((element, index) => props.itemKey(element) === items[index].id) + draggables.length === items.length && + draggables.every((element, index) => itemKey(element) === items[index].id) ) { return; } - setItems(wrap(props.draggables, props.itemKey)); - }, [props.draggables]); + setItems(wrap(draggables, itemKey)); + }, [draggables, itemKey, items]); const onDragEnd: OnDragEndResponder = useCallback( result => { @@ -94,9 +94,9 @@ function List(props: ListProps): JSX.Element { const reordered = reorder(items, result.source.index, result.destination.index); setItems(reordered); - props.onReordered(reordered.map(item => item.content)); + onReordered(reordered.map(item => item.content)); }, - [items] + [items, onReordered] ); return ( @@ -131,12 +131,8 @@ function List(props: ListProps): JSX.Element { ); }} > - {(provided, snapshot) => ( -
+ {provided => ( +
{items.map((item, index) => ( {draggableProvided => ( @@ -146,7 +142,7 @@ function List(props: ListProps): JSX.Element { style={{ ...draggableProvided.draggableProps.style, // if last item, don't add margin - marginBottom: `${props.gap}px`, + marginBottom: `${gap}px`, }} > {transformFunction(item.content, draggableProvided.dragHandleProps)} diff --git a/src/views/components/common/LogoIcon.tsx b/src/views/components/common/LogoIcon.tsx index a74afcdc..a6740de6 100644 --- a/src/views/components/common/LogoIcon.tsx +++ b/src/views/components/common/LogoIcon.tsx @@ -2,6 +2,11 @@ import clsx from 'clsx'; import type { SVGProps } from 'react'; import React from 'react'; +/** + * Renders the logo icon. + * @param {SVGProps} props - The SVG props. + * @returns {JSX.Element} The rendered logo icon. + */ export function LogoIcon(props: SVGProps): JSX.Element { return ( @@ -13,6 +18,12 @@ export function LogoIcon(props: SVGProps): JSX.Element { ); } +/** + * Renders the small logo. + * @param {Object} props - The component props. + * @param {string} props.className - The class name for the logo container. + * @returns {JSX.Element} The rendered small logo. + */ export function SmallLogo({ className }: { className?: string }): JSX.Element { return (
@@ -25,6 +36,12 @@ export function SmallLogo({ className }: { className?: string }): JSX.Element { ); } +/** + * Renders the large logo. + * @param {Object} props - The component props. + * @param {string} props.className - The class name for the logo container. + * @returns {JSX.Element} The rendered large logo. + */ export function LargeLogo({ className }: { className?: string }): JSX.Element { return (
diff --git a/src/views/components/common/PopupCourseBlock/PopupCourseBlock.tsx b/src/views/components/common/PopupCourseBlock/PopupCourseBlock.tsx index 1bdfff20..32a8922b 100644 --- a/src/views/components/common/PopupCourseBlock/PopupCourseBlock.tsx +++ b/src/views/components/common/PopupCourseBlock/PopupCourseBlock.tsx @@ -1,7 +1,7 @@ import { background } from '@shared/messages'; import type { Course } from '@shared/types/Course'; import { Status } from '@shared/types/Course'; -import type { CourseColors } from '@shared/util/colors'; +import type { CourseColors } from '@shared/types/ThemeColors'; import { pickFontColor } from '@shared/util/colors'; import { StatusIcon } from '@shared/util/icons'; import Text from '@views/components/common/Text/Text'; @@ -31,7 +31,7 @@ export default function PopupCourseBlock({ colors, dragHandleProps, }: PopupCourseBlockProps): JSX.Element { - // whiteText based on secondaryColor + // text-white or text-black based on secondaryColor const fontColor = pickFontColor(colors.primaryColor); const formattedUniqueId = course.uniqueId.toString().padStart(5, '0'); diff --git a/src/views/components/common/ScheduleListItem/ScheduleListItem.tsx b/src/views/components/common/ScheduleListItem/ScheduleListItem.tsx index 2e17828e..8abd96fd 100644 --- a/src/views/components/common/ScheduleListItem/ScheduleListItem.tsx +++ b/src/views/components/common/ScheduleListItem/ScheduleListItem.tsx @@ -28,15 +28,16 @@ export default function ScheduleListItem({ schedule, dragHandleProps, onClick }: const [editorValue, setEditorValue] = useState(schedule.name); const editorRef = React.useRef(null); - const { current: editor } = editorRef; useEffect(() => { + const editor = editorRef.current; + setEditorValue(schedule.name); if (isEditing && editor) { editor.focus(); editor.setSelectionRange(0, editor.value.length); } - }, [isEditing, schedule.name, editor]); + }, [isEditing, schedule.name, editorRef]); const isActive = useMemo(() => activeSchedule.id === schedule.id, [activeSchedule, schedule]); diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup.tsx index d053d723..ff92ec05 100644 --- a/src/views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup.tsx +++ b/src/views/components/injected/CourseCatalogInjectedPopup/CourseCatalogInjectedPopup.tsx @@ -8,6 +8,9 @@ import Description from './Description'; import GradeDistribution from './GradeDistribution'; import HeadingAndActions from './HeadingAndActions'; +/** + * Props for the CourseCatalogInjectedPopup component. + */ export type CourseCatalogInjectedPopupProps = DialogProps & { course: Course; }; diff --git a/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx b/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx index 4a52d1a8..26b4eaa1 100644 --- a/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx +++ b/src/views/components/injected/CourseCatalogInjectedPopup/GradeDistribution.tsx @@ -1,6 +1,6 @@ import type { Course } from '@shared/types/Course'; import type { Distribution, LetterGrade } from '@shared/types/Distribution'; -import { extendedColors } from '@shared/util/themeColors'; +import { extendedColors } from '@shared/types/ThemeColors'; import Spinner from '@views/components/common/Spinner/Spinner'; import Text from '@views/components/common/Text/Text'; import { diff --git a/src/views/hooks/useFlattenedCourseSchedule.ts b/src/views/hooks/useFlattenedCourseSchedule.ts index 66e5bb1e..656a90c7 100644 --- a/src/views/hooks/useFlattenedCourseSchedule.ts +++ b/src/views/hooks/useFlattenedCourseSchedule.ts @@ -1,5 +1,7 @@ +import type { HexColor } from '@shared/types/Color'; import type { Course, StatusType } from '@shared/types/Course'; import type { CourseMeeting } from '@shared/types/CourseMeeting'; +import { colors } from '@shared/types/ThemeColors'; import { UserSchedule } from '@shared/types/UserSchedule'; import type { CalendarCourseCellProps } from '@views/components/calendar/CalendarCourseCell/CalendarCourseCell'; @@ -19,9 +21,9 @@ interface CalendarGridPoint { endIndex: number; } -interface componentProps { - calendarCourseCellProps: CalendarCourseCellProps; -} +// interface componentProps { +// calendarCourseCellProps: CalendarCourseCellProps; +// } /** * Return type of useFlattenedCourseSchedule @@ -35,6 +37,9 @@ export interface CalendarGridCourse { totalColumns?: number; } +/** + * Represents a flattened course schedule. + */ export interface FlattenedCourseSchedule { courseCells: CalendarGridCourse[]; activeSchedule?: UserSchedule; @@ -96,15 +101,16 @@ export function useFlattenedCourseSchedule(): FlattenedCourseSchedule { } satisfies FlattenedCourseSchedule; } -// Helper function to extract and format basic course information +/** + * Function to extract and format basic course information + */ function extractCourseInfo(course: Course) { const { status, - department, - instructors, schedule: { meetings }, } = course; - const courseDeptAndInstr = `${department} ${instructors[0].lastName}`; + const courseDeptAndInstr = `${course.department} ${course.number} – ${course.instructors[0].lastName}`; + return { status, courseDeptAndInstr, meetings, course }; } @@ -131,8 +137,8 @@ function processAsyncCourses({ courseDeptAndInstr, status, colors: { - primaryColor: 'ut-gray', - secondaryColor: 'ut-gray', + primaryColor: colors.ut.gray as HexColor, + secondaryColor: colors.ut.gray as HexColor, }, }, course, @@ -149,8 +155,9 @@ function processInPersonMeetings(meeting: CourseMeeting, { courseDeptAndInstr, s const normalizingTimeFactor = 720; const time = meeting.getTimeString({ separator: '-', capitalize: true }); const timeAndLocation = `${time} - ${location ? location.building : 'WB'}`; - let normalizedStartTime = startTime >= midnightIndex ? startTime - normalizingTimeFactor : startTime; - let normalizedEndTime = endTime >= midnightIndex ? endTime - normalizingTimeFactor : endTime; + const normalizedStartTime = startTime >= midnightIndex ? startTime - normalizingTimeFactor : startTime; + const normalizedEndTime = endTime >= midnightIndex ? endTime - normalizingTimeFactor : endTime; + return days.map(day => ({ calendarGridPoint: { dayIndex: dayToNumber[day], @@ -162,8 +169,8 @@ function processInPersonMeetings(meeting: CourseMeeting, { courseDeptAndInstr, s timeAndLocation, status, colors: { - primaryColor: 'ut-orange', - secondaryColor: 'ut-orange', + primaryColor: colors.ut.orange as HexColor, + secondaryColor: colors.ut.orange as HexColor, }, }, course, diff --git a/src/views/hooks/useSchedules.ts b/src/views/hooks/useSchedules.ts index 0b64f443..dbe351eb 100644 --- a/src/views/hooks/useSchedules.ts +++ b/src/views/hooks/useSchedules.ts @@ -70,10 +70,19 @@ export default function useSchedules(): [active: UserSchedule, schedules: UserSc return [activeSchedule, schedules]; } +/** + * Returns the active schedule. + * @returns The active schedule. + */ export function getActiveSchedule(): UserSchedule { return schedulesCache[activeIndexCache] ?? errorSchedule; } +/** + * Replaces the old schedule with the new schedule. + * @param oldSchedule - The old schedule to be replaced. + * @param newSchedule - The new schedule to replace the old schedule. + */ export async function replaceSchedule(oldSchedule: UserSchedule, newSchedule: UserSchedule) { const schedules = await UserScheduleStore.get('schedules'); let oldIndex = schedules.findIndex(s => s.id === oldSchedule.id); diff --git a/src/views/index.tsx b/src/views/index.tsx index 3438371a..11600aaa 100644 --- a/src/views/index.tsx +++ b/src/views/index.tsx @@ -5,7 +5,6 @@ import CourseCatalogMain from './components/CourseCatalogMain'; import PopupMain from './components/PopupMain'; import getSiteSupport, { SiteSupport } from './lib/getSiteSupport'; import render from './lib/react'; -import colors from './styles/colors.module.scss'; const support = getSiteSupport(window.location.href); console.log('support:', support); diff --git a/unocss.config.ts b/unocss.config.ts index 350b4231..94f34783 100644 --- a/unocss.config.ts +++ b/unocss.config.ts @@ -4,7 +4,7 @@ import transformerDirectives from '@unocss/transformer-directives'; import transformerVariantGroup from '@unocss/transformer-variant-group'; import { defineConfig } from 'unocss'; -import { colors } from './src/shared/util/themeColors'; +import { colors } from './src/shared/types/ThemeColors'; export default defineConfig({ rules: [