Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Automatically adjust block colors to have a contrast ratio of 4.5 #9973

Merged
merged 4 commits into from
Apr 17, 2024
Merged
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
1 change: 1 addition & 0 deletions localtypings/pxtarget.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ declare namespace pxt {
timeMachineQueryParams?: string[]; // An array of query params to pass to timemachine iframe embed
timeMachineDiffInterval?: number; // An interval in milliseconds at which to take diffs to store in project history. Defaults to 5 minutes
timeMachineSnapshotInterval?: number; // An interval in milliseconds at which to take full project snapshots in project history. Defaults to 15 minutes
adjustBlockContrast?: boolean; // If set to true, all block colors will automatically be adjusted to have a contrast ratio of 4.5 with text
}

interface DownloadDialogTheme {
Expand Down
10 changes: 8 additions & 2 deletions pxtblocks/builtins/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export function initOnStart() {
const onStartDef = pxt.blocks.getBlockDefinition(ts.pxtc.ON_START_TYPE);
Blockly.Blocks[ts.pxtc.ON_START_TYPE] = {
init: function () {
let colorOverride = pxt.appTarget.runtime?.onStartColor;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we special-case the onStart block? Why?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on-start is kind of a special builtin block because we give targets a lot of control over where it shows up. For example, in micro:bit it's in the basic category but everywhere else it's in loops. iirc we added this option for cue because they had an on-start that doesn't show up in any category, it was just always on the workspace and couldn't be deleted.


if (colorOverride) {
colorOverride = pxt.toolbox.getAccessibleBackground(colorOverride);
}

this.jsonInit({
"message0": onStartDef.block["message0"],
"args0": [
Expand All @@ -19,15 +25,15 @@ export function initOnStart() {
"name": "HANDLER"
}
],
"colour": (pxt.appTarget.runtime ? pxt.appTarget.runtime.onStartColor : '') || pxt.toolbox.getNamespaceColor('loops')
"colour": colorOverride || pxt.toolbox.getNamespaceColor('loops')
});

setHelpResources(this,
ts.pxtc.ON_START_TYPE,
onStartDef.name,
onStartDef.tooltip,
onStartDef.url,
String((pxt.appTarget.runtime ? pxt.appTarget.runtime.onStartColor : '') || pxt.toolbox.getNamespaceColor('loops')),
colorOverride || pxt.toolbox.getNamespaceColor('loops'),
undefined, undefined,
pxt.appTarget.runtime ? pxt.appTarget.runtime.onStartUnDeletable : false
);
Expand Down
2 changes: 1 addition & 1 deletion pxtblocks/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ function initBlock(block: Blockly.Block, info: pxtc.BlocksInfo, fn: pxtc.SymbolI
const helpUrl = pxt.blocks.getHelpUrl(fn);
if (helpUrl) block.setHelpUrl(helpUrl)

block.setColour(color);
block.setColour(typeof color === "string" ? pxt.toolbox.getAccessibleBackground(color) : color);
let blockShape = provider.SHAPES.ROUND;
if (fn.retType == "boolean") {
blockShape = provider.SHAPES.HEXAGONAL;
Expand Down
115 changes: 115 additions & 0 deletions pxtlib/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
namespace pxt {
export function getWhiteContrastingBackground(color: string) {
if (contrastRatio("#ffffff", color) >= 4.5) return color;

const hslColor = hsl(color);

// There is probably a "smart" way to calculate this, but a cursory search
// didn't turn up anything so we're just going to decrease the luminosity
// until we get a contrasting color
const luminosityStep = 0.05;

let l = hslColor.l - luminosityStep;
while (l > 0) {
const newColor = hslToHex({ h: hslColor.h, s: hslColor.s, l });
if (contrastRatio("#ffffff", newColor) >= 4.5) return newColor;
l -= luminosityStep;
}

// We couldn't find one, so just return the original
console.warn(`Couldn't find a contrasting background for color ${color}`);
return color;
}

function hsl(color: string): { h: number, s: number, l: number } {
const rgb = pxt.toolbox.convertColor(color);

const r = parseInt(rgb.slice(1, 3), 16) / 255;
const g = parseInt(rgb.slice(3, 5), 16) / 255;
const b = parseInt(rgb.slice(5, 7), 16) / 255;

const max = Math.max(Math.max(r, g), b);
const min = Math.min(Math.min(r, g), b);

const diff = max - min;
let h;
if (diff === 0)
h = 0;
else if (max === r)
h = ((((g - b) / diff) % 6) + 6) % 6;
else if (max === g)
h = (b - r) / diff + 2;
else if (max === b)
h = (r - g) / diff + 4;

let l = (min + max) / 2;
let s = diff === 0
? 0
: diff / (1 - Math.abs(2 * l - 1));

return {
h: h * 60,
s,
l
}
}


function relativeLuminance(color: string) {
const rgb = pxt.toolbox.convertColor(color);

const r = parseInt(rgb.slice(1, 3), 16) / 255;
const g = parseInt(rgb.slice(3, 5), 16) / 255;
const b = parseInt(rgb.slice(5, 7), 16) / 255;

const r2 = (r <= 0.03928) ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
const g2 = (g <= 0.03928) ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
const b2 = (b <= 0.03928) ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);

return 0.2126 * r2 + 0.7152 * g2 + 0.0722 * b2;
}

function contrastRatio(fg: string, bg: string) {
return (relativeLuminance(fg) + 0.05) / (relativeLuminance(bg) + 0.05)
riknoll marked this conversation as resolved.
Show resolved Hide resolved
}

function hslToHex(color: { h: number, s: number, l: number }) {
const chroma = (1 - Math.abs(2 * color.l - 1)) * color.s;
const hp = color.h / 60.0;
// second largest component of this color
const x = chroma * (1 - Math.abs((hp % 2) - 1));

// 'point along the bottom three faces of the RGB cube'
let rgb1: number[];
if (color.h === undefined)
rgb1 = [0, 0, 0];
else if (hp <= 1)
rgb1 = [chroma, x, 0];
else if (hp <= 2)
rgb1 = [x, chroma, 0];
else if (hp <= 3)
rgb1 = [0, chroma, x];
else if (hp <= 4)
rgb1 = [0, x, chroma];
else if (hp <= 5)
rgb1 = [x, 0, chroma];
else if (hp <= 6)
rgb1 = [chroma, 0, x];

// lightness match component
let m = color.l - chroma * 0.5;
return toHexString(
Math.round(255 * (rgb1[0] + m)),
Math.round(255 * (rgb1[1] + m)),
Math.round(255 * (rgb1[2] + m))
);
}

function toHexString(r: number, g: number, b: number) {
return "#" + toHex(r) + toHex(g) + toHex(b);
}

function toHex(n: number) {
return ("0" + n.toString(16)).slice(-2)
}
}
33 changes: 29 additions & 4 deletions pxtlib/toolbox.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
namespace pxt.toolbox {
const cachedAccessibleColors: pxt.Map<string> = {};

export const blockColors: Map<number | string> = {
loops: '#107c10',
logic: '#006970',
Expand Down Expand Up @@ -63,10 +65,18 @@ namespace pxt.toolbox {

export function getNamespaceColor(ns: string): string {
ns = ns.toLowerCase();
if (pxt.appTarget.appTheme.blockColors && pxt.appTarget.appTheme.blockColors[ns])
return pxt.appTarget.appTheme.blockColors[ns] as string;
if (pxt.toolbox.blockColors[ns])
return pxt.toolbox.blockColors[ns] as string;

let color: string;
if (pxt.appTarget.appTheme.blockColors && pxt.appTarget.appTheme.blockColors[ns]) {
color = pxt.appTarget.appTheme.blockColors[ns] as string;
}
else if (pxt.toolbox.blockColors[ns]) {
color = pxt.toolbox.blockColors[ns] as string;
}

if (color) {
return getAccessibleBackground(color);
}
return "";
}

Expand Down Expand Up @@ -190,4 +200,19 @@ namespace pxt.toolbox {

return rgb;
}

/**
* Calculates an accessible background color assuming a foreground color of white and
* caches the result. Does not clear the cache, but this shouldn't be much of a memory
* concern since we only cache colors that are used in the toolbox.
*/
export function getAccessibleBackground(color: string) {
if (!pxt.appTarget?.appTheme?.adjustBlockContrast) return color;

if (!cachedAccessibleColors[color]) {
cachedAccessibleColors[color] = pxt.getWhiteContrastingBackground(color);
}

return cachedAccessibleColors[color];
}
}
88 changes: 42 additions & 46 deletions pxtrunner/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,54 +713,50 @@ function decompileApiAsync(options: ClientRenderOptions): Promise<DecompileResul
return decompileApiPromise;
}

function renderNamespaces(options: ClientRenderOptions): Promise<void> {
if (pxt.appTarget.id == "core") return Promise.resolve();
async function renderNamespaces(options: ClientRenderOptions): Promise<void> {
if (pxt.appTarget.id == "core") return;

const decompileResult = await decompileApiAsync(options);
const info = decompileResult.compileBlocks.blocksInfo;

let namespaceToColor: pxt.Map<string> = {};
for (const fn of info.blocks) {
const ns = (fn.attributes.blockNamespace || fn.namespace).split('.')[0];
if (!namespaceToColor[ns]) {
const nsn = info.apis.byQName[ns];
if (nsn && nsn.attributes.color)
namespaceToColor[ns] = nsn.attributes.color;
}
}

return decompileApiAsync(options)
.then((r) => {
let res: pxt.Map<string> = {};
const info = r.compileBlocks.blocksInfo;
info.blocks.forEach(fn => {
const ns = (fn.attributes.blockNamespace || fn.namespace).split('.')[0];
if (!res[ns]) {
const nsn = info.apis.byQName[ns];
if (nsn && nsn.attributes.color)
res[ns] = nsn.attributes.color;
let nsStyleBuffer = '';
for (const ns of Object.keys(namespaceToColor)) {
const color = pxt.getWhiteContrastingBackground(namespaceToColor[ns] || '#dddddd');
nsStyleBuffer += `
span.docs.${ns.toLowerCase()} {
background-color: ${color} !important;
border-color: ${pxt.toolbox.fadeColor(color, 0.1, false)} !important;
}
});
let nsStyleBuffer = '';
Object.keys(res).forEach(ns => {
const color = res[ns] || '#dddddd';
nsStyleBuffer += `
span.docs.${ns.toLowerCase()} {
background-color: ${color} !important;
border-color: ${pxt.toolbox.fadeColor(color, 0.1, false)} !important;
}
`;
})
return nsStyleBuffer;
})
.then((nsStyleBuffer) => {
Object.keys(pxt.toolbox.blockColors).forEach((ns) => {
const color = pxt.toolbox.getNamespaceColor(ns);
nsStyleBuffer += `
span.docs.${ns.toLowerCase()} {
background-color: ${color} !important;
border-color: ${pxt.toolbox.fadeColor(color, 0.1, false)} !important;
}
`;
})
return nsStyleBuffer;
})
.then((nsStyleBuffer) => {
// Inject css
let nsStyle = document.createElement('style');
nsStyle.id = "namespaceColors";
nsStyle.type = 'text/css';
let head = document.head || document.getElementsByTagName('head')[0];
head.appendChild(nsStyle);
nsStyle.appendChild(document.createTextNode(nsStyleBuffer));
});
`;
}

for (const ns of Object.keys(pxt.toolbox.blockColors)) {
const color = pxt.toolbox.getNamespaceColor(ns);
nsStyleBuffer += `
span.docs.${ns.toLowerCase()} {
background-color: ${color} !important;
border-color: ${pxt.toolbox.fadeColor(color, 0.1, false)} !important;
}
`;
}

// Inject css
let nsStyle = document.createElement('style');
nsStyle.id = "namespaceColors";
nsStyle.type = 'text/css';
let head = document.head || document.getElementsByTagName('head')[0];
head.appendChild(nsStyle);
nsStyle.appendChild(document.createTextNode(nsStyleBuffer));
}

function renderInlineBlocksAsync(options: BlocksRenderOptions): Promise<void> {
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/monacoFlyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ export class MonacoFlyout extends data.Component<MonacoFlyoutProps, MonacoFlyout

const snippet = isPython ? block.pySnippet : block.snippet;
const params = block.parameters;
const blockColor = block.attributes.color || color;
const blockColor = pxt.toolbox.getAccessibleBackground(block.attributes.color || color);
const blockDescription = this.getBlockDescription(block, params ? params.slice() : null);
const helpUrl = block.attributes.help;

Expand Down Expand Up @@ -404,7 +404,7 @@ export class MonacoFlyout extends data.Component<MonacoFlyoutProps, MonacoFlyout

renderCore() {
const { name, ns, color, icon, groups } = this.state;
const rgb = pxt.toolbox.convertColor(color || (ns && pxt.toolbox.getNamespaceColor(ns)) || "255");
const rgb = pxt.toolbox.getAccessibleBackground(pxt.toolbox.convertColor(color || (ns && pxt.toolbox.getNamespaceColor(ns)) || "255"));
const iconClass = `blocklyTreeIcon${icon ? (ns || icon).toLowerCase() : "Default"}`.replace(/\s/g, "");
return <div id="monacoFlyoutWidget" className="monacoFlyout" style={this.getFlyoutStyle()}>
<div id="monacoFlyoutWrapper" onScroll={this.scrollHandler} onWheel={this.wheelHandler} role="list">
Expand Down
4 changes: 3 additions & 1 deletion webapp/src/toolbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -731,7 +731,9 @@ export class TreeRow extends data.Component<TreeRowProps, {}> {

getMetaColor() {
const { color } = this.props.treeRow;
return pxt.toolbox.convertColor(color) || pxt.toolbox.getNamespaceColor('default');
return pxt.toolbox.getAccessibleBackground(
pxt.toolbox.convertColor(color) || pxt.toolbox.getNamespaceColor('default')
);
}

handleTreeRowRef = (c: HTMLDivElement) => {
Expand Down