diff --git a/apps/roam/package.json b/apps/roam/package.json index 229d94ca0..517e3dc11 100644 --- a/apps/roam/package.json +++ b/apps/roam/package.json @@ -17,7 +17,6 @@ "@repo/tailwind-config": "workspace:*", "@repo/types": "workspace:*", "@repo/typescript-config": "workspace:*", - "@types/contrast-color": "^1.0.0", "@types/file-saver": "2.0.5", "@types/nanoid": "2.0.0", "@types/react": "catalog:roam", @@ -54,7 +53,7 @@ "@vercel/blob": "^1.1.1", "classnames": "^2.3.2", "@hello-pangea/dnd": "^18.0.1", - "contrast-color": "^1.0.1", + "colord": "^2.9.3", "core-js": "^3.45.0", "cytoscape": "^3.21.0", "cytoscape-navigator": "^2.0.1", diff --git a/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx b/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx index 1658aeb75..c2e90dcb1 100644 --- a/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx +++ b/apps/roam/src/components/canvas/DiscourseNodeUtil.tsx @@ -33,7 +33,7 @@ import createDiscourseNode from "~/utils/createDiscourseNode"; import { DiscourseNode } from "~/utils/getDiscourseNodes"; import { isPageUid } from "./Tldraw"; import LabelDialog from "./LabelDialog"; -import ContrastColor from "contrast-color"; +import { colord } from "colord"; import { discourseContext } from "./Tldraw"; import getDiscourseContextResults from "~/utils/getDiscourseContextResults"; import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg"; @@ -46,6 +46,7 @@ import { } from "~/data/userSettings"; import { getSetting } from "~/utils/extensionSettings"; import DiscourseContextOverlay from "~/components/DiscourseContextOverlay"; +import getPleasingColors from "@repo/utils/getPleasingColors"; // TODO REPLACE WITH TLDRAW DEFAULTS // https://github.com/tldraw/tldraw/pull/1580/files @@ -350,13 +351,15 @@ export class BaseDiscourseNodeUtil extends ShapeUtil { ? discourseNodeIndex : 0 ]; - const formattedBackgroundColor = + const formattedTextColor = setColor && !setColor.startsWith("#") ? `#${setColor}` : setColor; - const backgroundColor = formattedBackgroundColor - ? formattedBackgroundColor + const canvasSelectedColor = formattedTextColor + ? formattedTextColor : COLOR_PALETTE[paletteColor]; - const textColor = ContrastColor.contrastColor({ bgColor: backgroundColor }); + const pleasingColors = getPleasingColors(colord(canvasSelectedColor)); + const backgroundColor = pleasingColors.background; + const textColor = pleasingColors.text; return { backgroundColor, textColor }; } diff --git a/apps/roam/src/utils/initializeObserversAndListeners.ts b/apps/roam/src/utils/initializeObserversAndListeners.ts index e6ed1cd2d..b6fb54dbf 100644 --- a/apps/roam/src/utils/initializeObserversAndListeners.ts +++ b/apps/roam/src/utils/initializeObserversAndListeners.ts @@ -50,6 +50,8 @@ import { getSetting } from "./extensionSettings"; import { mountLeftSidebar } from "~/components/LeftSidebarView"; import { getUidAndBooleanSetting } from "./getExportSettings"; import { getCleanTagText } from "~/components/settings/NodeConfig"; +import getPleasingColors from "@repo/utils/getPleasingColors"; +import { colord } from "colord"; const debounce = (fn: () => void, delay = 250) => { let timeout: number; @@ -117,7 +119,28 @@ export const initObservers = async ({ if (normalizedTag === normalizedNodeTag) { renderNodeTagPopupButton(s, node, onloadArgs.extensionAPI); if (node.canvasSettings?.color) { - s.style.color = formatHexColor(node.canvasSettings.color); + const formattedColor = formatHexColor(node.canvasSettings.color); + if (!formattedColor) { + break; + } + const contrastingColor = getPleasingColors( + colord(formattedColor), + ); + + Object.assign(s.style, { + backgroundColor: contrastingColor.background, + color: contrastingColor.text, + border: `1px solid ${contrastingColor.border}`, + fontWeight: "500", + padding: "2px 6px", + borderRadius: "12px", + margin: "0 2px", + fontSize: "0.9em", + whiteSpace: "nowrap", + boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)", + display: "inline-block", + cursor: "pointer", + }); } break; } diff --git a/packages/utils/package.json b/packages/utils/package.json index 22145026b..c8aec8100 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -16,5 +16,8 @@ "build": "tsc", "check-types": "tsc --noEmit --skipLibCheck", "lint": "eslint ." + }, + "dependencies": { + "colord": "^2.9.3" } } diff --git a/packages/utils/src/getPleasingColors.ts b/packages/utils/src/getPleasingColors.ts new file mode 100644 index 000000000..f7f9b65f0 --- /dev/null +++ b/packages/utils/src/getPleasingColors.ts @@ -0,0 +1,191 @@ +import { colord, extend, type Colord } from "colord"; +import a11yPlugin from "colord/plugins/a11y"; +import mixPlugin from "colord/plugins/mix"; +extend([a11yPlugin, mixPlugin]); + +type PleasingColorScheme = { + primary: string; // input color + background: string; // lighter on-hue bg + text: string; // darker on-hue text + border: string; // mid-tone border + contrastRatio: number; + level: "AAA" | "AA"; +}; + +const searchNeutralTextForAA = ( + bg: Colord, + target = 4.5, +): { c: Colord; cr: number } => { + // Search along neutral gray axis for the lightest/darkest text that passes. + // For a light bg we’ll search dark grays [0..40] and pick the lightest that meets target. + let lo = 0, + hi = 40; // dark side only (we’re in on-light mode) + let best: { c: Colord; cr: number } | null = null; + for (let i = 0; i < 24; i++) { + const mid = (lo + hi) / 2; + const t = colord({ h: 0, s: 0, l: mid }); // neutral gray + const cr = bg.contrast(t); + if (cr >= target) { + best = { c: t, cr }; // keep the **lightest** passing dark gray + hi = mid - 0.0001; + } else { + lo = mid + 0.0001; + } + if (Math.abs(hi - lo) < 0.0001) break; + } + // If nothing found (shouldn't happen for a light bg), return hard black. + return best ?? { c: colord("#000000"), cr: bg.contrast("#000") }; +}; + +const setLightness = (c: Colord, lightness: number): Colord => { + const { h, s } = c.toHsl(); + return colord({ h, s, l: Math.max(0, Math.min(100, lightness)) }); +}; + +// Search lightness of 'a' (keeping hue & sat), to reach target contrast vs fixed 'b' +const searchTone = ( + aSeed: Colord, + bFixed: Colord, + options: { target: number; lowL: number; highL: number; maxIter?: number }, +): { c: Colord; cr: number } | null => { + const { target, lowL, highL, maxIter = 24 } = options; + let lo = lowL, + hi = highL; + let best: { c: Colord; cr: number } | null = null; + + for (let i = 0; i < maxIter; i++) { + const mid = (lo + hi) / 2; + const candidate = setLightness(aSeed, mid); + const cr = candidate.contrast(bFixed); + + if (cr >= target && (!best || cr < best.cr)) best = { c: candidate, cr }; + + // move the candidate farther from bFixed’s lightness when contrast is too low + const aL = candidate.toHsl().l; + const bL = bFixed.toHsl().l; + const aIsLighter = aL > bL; + + if (cr < target) { + // push 'a' away from 'b' in lightness space + if (aIsLighter) hi = mid - 0.0001; + else lo = mid + 0.0001; + } else { + // we have enough contrast; try to bring them a tad closer (softer) + if (aIsLighter) lo = mid + 0.0001; + else hi = mid - 0.0001; + } + + if (Math.abs(hi - lo) < 0.0001) break; + } + + return best; +}; + +// Gentle desat for very light BGs to avoid chalkiness +const softenBg = (c: Colord, amt = 0.1) => { + const { h, s, l } = c.toHsl(); + const s2 = l > 85 ? s * (1 - amt) : s; // only soften very light tones + return colord({ h, s: Math.max(0, Math.min(100, s2)), l }); +}; + +const findTextWithTargetContrast = ( + textSeed: Colord, + background: Colord, +): { text: Colord; level: "AAA" | "AA" } => { + const maxTextLightness = Math.min(60, background.toHsl().l - 5); + + // Try AAA first + let textAAA = searchTone(textSeed, background, { + target: 7.0, + lowL: 2, + highL: maxTextLightness, + }); + let level: "AAA" | "AA" = "AAA"; + + // If AAA fails, try AA for text; still keeping hue/sat + if (!textAAA) { + textAAA = searchTone(textSeed, background, { + target: 4.5, + lowL: 2, + highL: maxTextLightness, + }); + level = "AA"; + } + + return { text: textAAA?.c ?? textSeed, level }; +}; + +export const getPleasingColors = (inputColor: Colord): PleasingColorScheme => { + const base = inputColor; + const { h, s, l } = base.toHsl(); + const AAA = 7.0, + AA = 4.5; + + // Seed a light background (on-light aesthetic), keep hue/sat + let bgSeed = colord({ h, s, l: Math.max(88, Math.min(94, Math.max(l, 90))) }); + bgSeed = softenBg(bgSeed, 0.12); + + // Seed text by nudging darker than base but not forcing to 18–32 band + const textSeed = colord({ h, s, l: Math.max(8, Math.min(50, l - 35)) }); + + // Find text color that meets contrast requirements + const { text: initialText, level: initialLevel } = findTextWithTargetContrast( + textSeed, + bgSeed, + ); + + // try adjusting BG instead, keeping the text colorful & near seed. + let text = initialText; + let bg = bgSeed; + let cr = bg.contrast(text); + let level = initialLevel; + + if ( + (initialLevel === "AAA" && cr < AAA) || + (initialLevel === "AA" && cr < AA) + ) { + // Re-search BG lightness against the chosen text + const tL = text.toHsl().l; + const bgSearch = searchTone(bgSeed, text, { + target: initialLevel === "AAA" ? AAA : AA, + lowL: Math.max(tL + 5, 70), + highL: 98, + }); + if (bgSearch) { + bg = bgSearch.c; + cr = bgSearch.cr; + } else { + cr = bg.contrast(text); + } + } + + if (cr < AA) { + // neutral fallback: choose the **lightest dark gray** that passes AA vs bg + const nf = searchNeutralTextForAA(bg, AA); + const textNeutral = nf.c; + const crNeutral = nf.cr; + + // replace text/cr with neutral solution + if (crNeutral >= AA) { + text = textNeutral; + cr = crNeutral; + level = cr >= AAA ? "AAA" : "AA"; + } + } + + // Border = mid L between bg/text (slight desat) + const midL = (bg.toHsl().l + text.toHsl().l) / 2; + const border = softenBg(setLightness(base, midL), 0.25); + + return { + primary: base.toHex(), + background: bg.toHex(), + text: text.toHex(), // stays on-hue & saturated + border: border.toHex(), + contrastRatio: Number(cr.toFixed(2)), + level, + }; +}; + +export default getPleasingColors; +export type { PleasingColorScheme }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3fae52b98..f7e151bcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,9 +227,9 @@ importers: classnames: specifier: ^2.3.2 version: 2.5.1 - contrast-color: - specifier: ^1.0.1 - version: 1.0.1 + colord: + specifier: ^2.9.3 + version: 2.9.3 core-js: specifier: ^3.45.0 version: 3.45.1 @@ -309,9 +309,6 @@ importers: '@repo/typescript-config': specifier: workspace:* version: link:../../packages/typescript-config - '@types/contrast-color': - specifier: ^1.0.0 - version: 1.0.3 '@types/file-saver': specifier: 2.0.5 version: 2.0.5 @@ -639,6 +636,10 @@ importers: version: 5.5.4 packages/utils: + dependencies: + colord: + specifier: ^2.9.3 + version: 2.9.3 devDependencies: '@repo/eslint-config': specifier: workspace:* @@ -3591,9 +3592,6 @@ packages: '@types/codemirror@5.60.8': resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==} - '@types/contrast-color@1.0.3': - resolution: {integrity: sha512-D4xFbzgt7p6o2I6e6vxxbSuDw8VbTDb6XGurz2MFi39WmmlDRmPvEcATqsfLShLM8GxrhZ0gWT+9VdGnC1DCTA==} - '@types/cookie@0.6.0': resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} @@ -4614,6 +4612,9 @@ packages: resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} engines: {node: '>=12.5.0'} + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -4670,10 +4671,6 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - contrast-color@1.0.1: - resolution: {integrity: sha512-XeTV/LiyWrf/OWnODTqve2YGBfg32N6zlLqQjJKmEY+ffDqIfecgdmluVz7tky1D4VEaweZgoeRJJT87gDSDCQ==} - engines: {npm: '>= 4.0.0'} - convert-hrtime@3.0.0: resolution: {integrity: sha512-7V+KqSvMiHp8yWDuwfww06XleMWVVB9b9tURBx+G7UTADuo5hYPuowKloz4OzOqbPezxgo+fdQ1522WzPG4OeA==} engines: {node: '>=8'} @@ -13105,8 +13102,6 @@ snapshots: dependencies: '@types/tern': 0.23.9 - '@types/contrast-color@1.0.3': {} - '@types/cookie@0.6.0': {} '@types/core-js@2.5.8': {} @@ -14333,6 +14328,8 @@ snapshots: color-convert: 2.0.1 color-string: 1.9.1 + colord@2.9.3: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -14375,8 +14372,6 @@ snapshots: content-type@1.0.5: {} - contrast-color@1.0.1: {} - convert-hrtime@3.0.0: {} convert-source-map@2.0.0: {}