Skip to content

Commit

Permalink
Automatically adjust block colors to have a contrast ratio of 4.5 (#9973
Browse files Browse the repository at this point in the history
)

* automatically adjust blocks colors to have a contrast ratio of 4.5

* add feature flag

* fix on start color
  • Loading branch information
riknoll authored Apr 17, 2024
1 parent 7e3400c commit e381259
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 56 deletions.
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;

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)
}

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

0 comments on commit e381259

Please sign in to comment.