Skip to content

Commit 510ac31

Browse files
authored
ENG-836 make button like css for node tag (#443)
* make button like css for node tag * fix color schemes * use colord * address review * remove package lock * use pleasing algorithm * fix lint * fix package imports
1 parent a449ecd commit 510ac31

File tree

6 files changed

+239
-25
lines changed

6 files changed

+239
-25
lines changed

apps/roam/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
"@repo/tailwind-config": "workspace:*",
1818
"@repo/types": "workspace:*",
1919
"@repo/typescript-config": "workspace:*",
20-
"@types/contrast-color": "^1.0.0",
2120
"@types/file-saver": "2.0.5",
2221
"@types/nanoid": "2.0.0",
2322
"@types/react": "catalog:roam",
@@ -54,7 +53,7 @@
5453
"@vercel/blob": "^1.1.1",
5554
"classnames": "^2.3.2",
5655
"@hello-pangea/dnd": "^18.0.1",
57-
"contrast-color": "^1.0.1",
56+
"colord": "^2.9.3",
5857
"core-js": "^3.45.0",
5958
"cytoscape": "^3.21.0",
6059
"cytoscape-navigator": "^2.0.1",

apps/roam/src/components/canvas/DiscourseNodeUtil.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import createDiscourseNode from "~/utils/createDiscourseNode";
3333
import { DiscourseNode } from "~/utils/getDiscourseNodes";
3434
import { isPageUid } from "./Tldraw";
3535
import LabelDialog from "./LabelDialog";
36-
import ContrastColor from "contrast-color";
36+
import { colord } from "colord";
3737
import { discourseContext } from "./Tldraw";
3838
import getDiscourseContextResults from "~/utils/getDiscourseContextResults";
3939
import calcCanvasNodeSizeAndImg from "~/utils/calcCanvasNodeSizeAndImg";
@@ -46,6 +46,7 @@ import {
4646
} from "~/data/userSettings";
4747
import { getSetting } from "~/utils/extensionSettings";
4848
import DiscourseContextOverlay from "~/components/DiscourseContextOverlay";
49+
import getPleasingColors from "@repo/utils/getPleasingColors";
4950

5051
// TODO REPLACE WITH TLDRAW DEFAULTS
5152
// https://github.com/tldraw/tldraw/pull/1580/files
@@ -350,13 +351,15 @@ export class BaseDiscourseNodeUtil extends ShapeUtil<DiscourseNodeShape> {
350351
? discourseNodeIndex
351352
: 0
352353
];
353-
const formattedBackgroundColor =
354+
const formattedTextColor =
354355
setColor && !setColor.startsWith("#") ? `#${setColor}` : setColor;
355356

356-
const backgroundColor = formattedBackgroundColor
357-
? formattedBackgroundColor
357+
const canvasSelectedColor = formattedTextColor
358+
? formattedTextColor
358359
: COLOR_PALETTE[paletteColor];
359-
const textColor = ContrastColor.contrastColor({ bgColor: backgroundColor });
360+
const pleasingColors = getPleasingColors(colord(canvasSelectedColor));
361+
const backgroundColor = pleasingColors.background;
362+
const textColor = pleasingColors.text;
360363
return { backgroundColor, textColor };
361364
}
362365

apps/roam/src/utils/initializeObserversAndListeners.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ import { getSetting } from "./extensionSettings";
5050
import { mountLeftSidebar } from "~/components/LeftSidebarView";
5151
import { getUidAndBooleanSetting } from "./getExportSettings";
5252
import { getCleanTagText } from "~/components/settings/NodeConfig";
53+
import getPleasingColors from "@repo/utils/getPleasingColors";
54+
import { colord } from "colord";
5355

5456
const debounce = (fn: () => void, delay = 250) => {
5557
let timeout: number;
@@ -117,7 +119,28 @@ export const initObservers = async ({
117119
if (normalizedTag === normalizedNodeTag) {
118120
renderNodeTagPopupButton(s, node, onloadArgs.extensionAPI);
119121
if (node.canvasSettings?.color) {
120-
s.style.color = formatHexColor(node.canvasSettings.color);
122+
const formattedColor = formatHexColor(node.canvasSettings.color);
123+
if (!formattedColor) {
124+
break;
125+
}
126+
const contrastingColor = getPleasingColors(
127+
colord(formattedColor),
128+
);
129+
130+
Object.assign(s.style, {
131+
backgroundColor: contrastingColor.background,
132+
color: contrastingColor.text,
133+
border: `1px solid ${contrastingColor.border}`,
134+
fontWeight: "500",
135+
padding: "2px 6px",
136+
borderRadius: "12px",
137+
margin: "0 2px",
138+
fontSize: "0.9em",
139+
whiteSpace: "nowrap",
140+
boxShadow: "0 1px 2px rgba(0, 0, 0, 0.05)",
141+
display: "inline-block",
142+
cursor: "pointer",
143+
});
121144
}
122145
break;
123146
}

packages/utils/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,8 @@
1616
"build": "tsc",
1717
"check-types": "tsc --noEmit --skipLibCheck",
1818
"lint": "eslint ."
19+
},
20+
"dependencies": {
21+
"colord": "^2.9.3"
1922
}
2023
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import { colord, extend, type Colord } from "colord";
2+
import a11yPlugin from "colord/plugins/a11y";
3+
import mixPlugin from "colord/plugins/mix";
4+
extend([a11yPlugin, mixPlugin]);
5+
6+
type PleasingColorScheme = {
7+
primary: string; // input color
8+
background: string; // lighter on-hue bg
9+
text: string; // darker on-hue text
10+
border: string; // mid-tone border
11+
contrastRatio: number;
12+
level: "AAA" | "AA";
13+
};
14+
15+
const searchNeutralTextForAA = (
16+
bg: Colord,
17+
target = 4.5,
18+
): { c: Colord; cr: number } => {
19+
// Search along neutral gray axis for the lightest/darkest text that passes.
20+
// For a light bg we’ll search dark grays [0..40] and pick the lightest that meets target.
21+
let lo = 0,
22+
hi = 40; // dark side only (we’re in on-light mode)
23+
let best: { c: Colord; cr: number } | null = null;
24+
for (let i = 0; i < 24; i++) {
25+
const mid = (lo + hi) / 2;
26+
const t = colord({ h: 0, s: 0, l: mid }); // neutral gray
27+
const cr = bg.contrast(t);
28+
if (cr >= target) {
29+
best = { c: t, cr }; // keep the **lightest** passing dark gray
30+
hi = mid - 0.0001;
31+
} else {
32+
lo = mid + 0.0001;
33+
}
34+
if (Math.abs(hi - lo) < 0.0001) break;
35+
}
36+
// If nothing found (shouldn't happen for a light bg), return hard black.
37+
return best ?? { c: colord("#000000"), cr: bg.contrast("#000") };
38+
};
39+
40+
const setLightness = (c: Colord, lightness: number): Colord => {
41+
const { h, s } = c.toHsl();
42+
return colord({ h, s, l: Math.max(0, Math.min(100, lightness)) });
43+
};
44+
45+
// Search lightness of 'a' (keeping hue & sat), to reach target contrast vs fixed 'b'
46+
const searchTone = (
47+
aSeed: Colord,
48+
bFixed: Colord,
49+
options: { target: number; lowL: number; highL: number; maxIter?: number },
50+
): { c: Colord; cr: number } | null => {
51+
const { target, lowL, highL, maxIter = 24 } = options;
52+
let lo = lowL,
53+
hi = highL;
54+
let best: { c: Colord; cr: number } | null = null;
55+
56+
for (let i = 0; i < maxIter; i++) {
57+
const mid = (lo + hi) / 2;
58+
const candidate = setLightness(aSeed, mid);
59+
const cr = candidate.contrast(bFixed);
60+
61+
if (cr >= target && (!best || cr < best.cr)) best = { c: candidate, cr };
62+
63+
// move the candidate farther from bFixed’s lightness when contrast is too low
64+
const aL = candidate.toHsl().l;
65+
const bL = bFixed.toHsl().l;
66+
const aIsLighter = aL > bL;
67+
68+
if (cr < target) {
69+
// push 'a' away from 'b' in lightness space
70+
if (aIsLighter) hi = mid - 0.0001;
71+
else lo = mid + 0.0001;
72+
} else {
73+
// we have enough contrast; try to bring them a tad closer (softer)
74+
if (aIsLighter) lo = mid + 0.0001;
75+
else hi = mid - 0.0001;
76+
}
77+
78+
if (Math.abs(hi - lo) < 0.0001) break;
79+
}
80+
81+
return best;
82+
};
83+
84+
// Gentle desat for very light BGs to avoid chalkiness
85+
const softenBg = (c: Colord, amt = 0.1) => {
86+
const { h, s, l } = c.toHsl();
87+
const s2 = l > 85 ? s * (1 - amt) : s; // only soften very light tones
88+
return colord({ h, s: Math.max(0, Math.min(100, s2)), l });
89+
};
90+
91+
const findTextWithTargetContrast = (
92+
textSeed: Colord,
93+
background: Colord,
94+
): { text: Colord; level: "AAA" | "AA" } => {
95+
const maxTextLightness = Math.min(60, background.toHsl().l - 5);
96+
97+
// Try AAA first
98+
let textAAA = searchTone(textSeed, background, {
99+
target: 7.0,
100+
lowL: 2,
101+
highL: maxTextLightness,
102+
});
103+
let level: "AAA" | "AA" = "AAA";
104+
105+
// If AAA fails, try AA for text; still keeping hue/sat
106+
if (!textAAA) {
107+
textAAA = searchTone(textSeed, background, {
108+
target: 4.5,
109+
lowL: 2,
110+
highL: maxTextLightness,
111+
});
112+
level = "AA";
113+
}
114+
115+
return { text: textAAA?.c ?? textSeed, level };
116+
};
117+
118+
export const getPleasingColors = (inputColor: Colord): PleasingColorScheme => {
119+
const base = inputColor;
120+
const { h, s, l } = base.toHsl();
121+
const AAA = 7.0,
122+
AA = 4.5;
123+
124+
// Seed a light background (on-light aesthetic), keep hue/sat
125+
let bgSeed = colord({ h, s, l: Math.max(88, Math.min(94, Math.max(l, 90))) });
126+
bgSeed = softenBg(bgSeed, 0.12);
127+
128+
// Seed text by nudging darker than base but not forcing to 18–32 band
129+
const textSeed = colord({ h, s, l: Math.max(8, Math.min(50, l - 35)) });
130+
131+
// Find text color that meets contrast requirements
132+
const { text: initialText, level: initialLevel } = findTextWithTargetContrast(
133+
textSeed,
134+
bgSeed,
135+
);
136+
137+
// try adjusting BG instead, keeping the text colorful & near seed.
138+
let text = initialText;
139+
let bg = bgSeed;
140+
let cr = bg.contrast(text);
141+
let level = initialLevel;
142+
143+
if (
144+
(initialLevel === "AAA" && cr < AAA) ||
145+
(initialLevel === "AA" && cr < AA)
146+
) {
147+
// Re-search BG lightness against the chosen text
148+
const tL = text.toHsl().l;
149+
const bgSearch = searchTone(bgSeed, text, {
150+
target: initialLevel === "AAA" ? AAA : AA,
151+
lowL: Math.max(tL + 5, 70),
152+
highL: 98,
153+
});
154+
if (bgSearch) {
155+
bg = bgSearch.c;
156+
cr = bgSearch.cr;
157+
} else {
158+
cr = bg.contrast(text);
159+
}
160+
}
161+
162+
if (cr < AA) {
163+
// neutral fallback: choose the **lightest dark gray** that passes AA vs bg
164+
const nf = searchNeutralTextForAA(bg, AA);
165+
const textNeutral = nf.c;
166+
const crNeutral = nf.cr;
167+
168+
// replace text/cr with neutral solution
169+
if (crNeutral >= AA) {
170+
text = textNeutral;
171+
cr = crNeutral;
172+
level = cr >= AAA ? "AAA" : "AA";
173+
}
174+
}
175+
176+
// Border = mid L between bg/text (slight desat)
177+
const midL = (bg.toHsl().l + text.toHsl().l) / 2;
178+
const border = softenBg(setLightness(base, midL), 0.25);
179+
180+
return {
181+
primary: base.toHex(),
182+
background: bg.toHex(),
183+
text: text.toHex(), // stays on-hue & saturated
184+
border: border.toHex(),
185+
contrastRatio: Number(cr.toFixed(2)),
186+
level,
187+
};
188+
};
189+
190+
export default getPleasingColors;
191+
export type { PleasingColorScheme };

pnpm-lock.yaml

Lines changed: 12 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)