Skip to content
This repository was archived by the owner on Feb 6, 2024. It is now read-only.
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
### Others

- deck-utils: v2.4.0 ([CHANGELOG](https://github.com/deckgo/deckdeckgo/blob/master/utils/deck/CHANGELOG.md))
- utils: v1.2.0 ([CHANGELOG](https://github.com/deckgo/deckdeckgo/blob/master/utils/utils/CHANGELOG.md))
- utils: v1.3.0 ([CHANGELOG](https://github.com/deckgo/deckdeckgo/blob/master/utils/utils/CHANGELOG.md))
- starter kit: v2.6.4 ([CHANGELOG](https://github.com/deckgo/deckdeckgo-starter/blob/master/CHANGELOG.md))

<a name="2.1.0"></a>
Expand Down
13 changes: 10 additions & 3 deletions studio/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"@deckdeckgo/slide-title": "^1.1.3",
"@deckdeckgo/slide-youtube": "^1.1.2",
"@deckdeckgo/social": "^2.0.0",
"@deckdeckgo/utils": "^1.2.0",
"@deckdeckgo/utils": "^1.3.0",
"@deckdeckgo/youtube": "^1.1.2",
"@ionic/core": "^5.3.1",
"firebase": "^7.17.2",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
app-slide-contrast {
position: absolute;
top: 16px;
left: 16px;

transition: opacity 0.5s;

visibility: initial;
opacity: 1;

&:not(.warning) {
visibility: hidden;
opacity: 0;
}

button {
display: flex;
justify-content: center;
align-items: center;

background: var(--ion-color-warning);
color: var(--ion-color-warning-contrast);

padding: 6px 12px;
border-radius: 64px;

position: relative;
overflow: hidden;

outline: 0;

box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);

font-size: var(--font-size-small);

ion-label {
margin-right: 4px;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {Component, h, Host, Listen, State} from '@stencil/core';

import {popoverController} from '@ionic/core';

import {ContrastUtils} from '../../../utils/editor/contrast.utils';
import {NodeUtils} from '../../../utils/editor/node.utils';

@Component({
tag: 'app-slide-contrast',
styleUrl: 'app-slide-contrast.scss',
})
export class AppSlideContrast {
private readonly lowestAACompliantLevel: number = 3;

@State()
private warning: boolean = false;

@Listen('slidesDidLoad', {target: 'document'})
async onSlidesDidLoad() {
await this.analyzeContrast();
}

@Listen('slideDidUpdate', {target: 'document'})
async onSlideDidUpdate() {
await this.analyzeContrast();
}

@Listen('deckDidChange', {target: 'document'})
async onDeckDidChange() {
await this.analyzeContrast();
}

@Listen('slideNextDidChange', {target: 'document'})
@Listen('slidePrevDidChange', {target: 'document'})
@Listen('slideToChange', {target: 'document'})
async onSlideNavigate() {
await this.analyzeContrast();
}

private async analyzeContrast() {
this.warning = await this.hasLowContrast();
}

private async hasLowContrast(): Promise<boolean> {
const deck: HTMLElement = document.querySelector('main > deckgo-deck');

if (!deck) {
return false;
}

const index = await (deck as any).getActiveIndex();

const slide: HTMLElement = deck.querySelector('.deckgo-slide-container:nth-child(' + (index + 1) + ')');

if (!slide) {
return false;
}

const slots: NodeListOf<HTMLElement> = slide.querySelectorAll(
'[slot="title"]:not(:empty),[slot="content"]:not(:empty),[slot="start"]:not(:empty),[slot="end"]:not(:empty),[slot="header"]:not(:empty),[slot="footer"]:not(:empty),[slot="author"]:not(:empty),deckgo-drr > section:not(:empty)'
);

if (!slots || slots.length <= 0) {
return false;
}

// Slots with direct text children
const slotsWithText: HTMLElement[] = await NodeUtils.childrenTextNode(slots);

// All children (<span/>) of the slots
const children: HTMLElement[] = await NodeUtils.children(slots);

const elements: HTMLElement[] =
children && children.length > 0
? slotsWithText && slotsWithText.length > 0
? [...Array.from(slotsWithText), ...children]
: [...children]
: slotsWithText && slotsWithText.length > 0
? [...slotsWithText]
: null;

if (!elements) {
return false;
}

const promises: Promise<number>[] = Array.from(elements).map((element: HTMLElement) => this.calculateRatio(element, deck, slide));

const contrasts: number[] = await Promise.all(promises);

if (!contrasts || contrasts.length <= 0) {
return false;
}

const lowContrast: number | undefined = contrasts.find((contrast: number) => contrast < this.lowestAACompliantLevel);

return lowContrast !== undefined;
}

private async calculateRatio(element: HTMLElement, deck: HTMLElement, slide: HTMLElement) {
const bgColor = await NodeUtils.findColors(element, 'background', deck, slide);
const color = await NodeUtils.findColors(element, 'color', deck, slide);

console.log('yo', bgColor, color);

return ContrastUtils.calculateContrastRatio(bgColor, color);
}

private async openInformation($event: UIEvent) {
const popover: HTMLIonPopoverElement = await popoverController.create({
component: 'app-contrast-info',
event: $event,
mode: 'ios',
cssClass: 'info',
});

await popover.present();
}

render() {
return (
<Host
class={{
warning: this.warning,
}}>
<button class="ion-activatable" onClick={($event: UIEvent) => this.openInformation($event)}>
<ion-ripple-effect></ion-ripple-effect>
<ion-label>Low contrast</ion-label>
<ion-icon name="warning-outline"></ion-icon>
</button>
</Host>
);
}
}
1 change: 1 addition & 0 deletions studio/src/app/pages/editor/app-editor/app-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ export class AppEditor {
{this.footer}
</deckgo-deck>
<deckgo-remote autoConnect={false}></deckgo-remote>
<app-slide-contrast></app-slide-contrast>
</main>
</ion-content>,
<ion-footer class={this.presenting ? 'idle' : undefined}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {Component, Element, h} from '@stencil/core';

@Component({
tag: 'app-contrast-info',
})
export class AppContrastInfo {
@Element() el: HTMLElement;

private async closePopover() {
await (this.el.closest('ion-popover') as HTMLIonPopoverElement).dismiss();
}

render() {
return (
<div class="ion-padding">
<h2>Low contrast</h2>
<p>We noticed that (a part of) the text color of this slide does not meet contrast ratio standards.</p>
<p>
Elements are compared according{' '}
<a href="https://www.w3.org/TR/WCAG/#contrast-minimum" target="_blank" rel="noopener noreferrer">
WCAG
</a>{' '}
Level AA.
</p>

<p>Note that if you are using semi-transparent background, the contrast ratio cannot be precise.</p>
<div class="ion-text-center">
<ion-button size="small" shape="round" color="primary" onClick={() => this.closePopover()}>
Got it
</ion-button>
</div>
</div>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class AppGetHelp {
</a>
.
</p>
<div class="ion-text-center ion-padding-top">
<div class="ion-text-center">
<ion-button size="small" shape="round" color="primary" onClick={() => this.closePopover()}>
Got it
</ion-button>
Expand Down
110 changes: 110 additions & 0 deletions studio/src/app/utils/editor/contrast.utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {extractRgb, extractRgba} from '@deckdeckgo/utils';

export class ContrastUtils {
static async calculateContrastRatio(bgColor: string | undefined, color: string | undefined): Promise<number> {
const bgColorWithDefault: string = bgColor === undefined || bgColor === '' ? `rgb(255, 255, 255)` : bgColor;
const colorWithDefault: string = color === undefined || color === '' ? `rgb(0, 0, 0)` : color;

// The text color may or may not be semi-transparent, but that doesn't matter
const bgRgba: number[] | undefined = extractRgba(bgColorWithDefault);

if (!bgRgba || bgRgba.length < 4 || bgRgba[3] >= 1) {
return this.calculateContrastRatioOpaque(bgColorWithDefault, colorWithDefault);
}

return this.calculateContrastRatioAlpha(bgColorWithDefault, colorWithDefault);
}

private static calculateLuminance(rgb: number[]): number {
const a = rgb.map((v) => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
}

private static calculateColorContrastRatio(firstColorLum: number, secondColorLum: number): number {
// return firstColorLum > secondColorLum ? (secondColorLum + 0.05) / (firstColorLum + 0.05) : (firstColorLum + 0.05) / (secondColorLum + 0.05);

const l1 = firstColorLum + 0.05;
const l2 = secondColorLum + 0.05;

let ratio = l1 / l2;

if (l2 > l1) {
ratio = 1 / ratio;
}

return ratio;
}

// Source: https://github.com/LeaVerou/contrast-ratio/blob/eb7fe8f16206869f8d36d517d7eb0962830d0e81/color.js#L86
private static async convertAlphaRgba(color: string, base: number[]): Promise<string> {
const rgba: number[] | undefined = extractRgba(color);

if (!rgba || rgba.length < 4) {
return color;
}

const alpha: number = rgba[3];

const rgb: number[] = [];

for (let i = 0; i < 3; i++) {
rgb.push(rgba[i] * alpha + base[i] * base[3] * (1 - alpha));
}

// Not used here
// rgb[3] = alpha + base[3] * (1 - alpha);

return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
}

private static async calculateColorContrastRatioWithBase(
bgColor: string,
lumColor: number,
base: number[]
): Promise<{luminanceOverlay: number; ratio: number}> {
const overlay = extractRgb(await this.convertAlphaRgba(bgColor, base));

const lumOverlay: number = this.calculateLuminance(overlay);

return {
luminanceOverlay: lumOverlay,
ratio: this.calculateColorContrastRatio(lumOverlay, lumColor),
};
}

private static async calculateContrastRatioAlpha(bgColor: string, color: string): Promise<number> {
const lumColor: number = this.calculateLuminance(extractRgb(color));

const onBlack: {luminanceOverlay: number; ratio: number} = await this.calculateColorContrastRatioWithBase(bgColor, lumColor, [0, 0, 0, 1]);
const onWhite: {luminanceOverlay: number; ratio: number} = await this.calculateColorContrastRatioWithBase(bgColor, lumColor, [255, 255, 255, 1]);

const max = Math.max(onBlack.ratio, onWhite.ratio);

let min = 1;
if (onBlack.luminanceOverlay > lumColor) {
min = onBlack.ratio;
} else if (onWhite.luminanceOverlay < lumColor) {
min = onWhite.ratio;
}

return (min + max) / 2;
}

private static async calculateContrastRatioOpaque(bgColor: string, color: string): Promise<number> {
const bgRgb: number[] | undefined = extractRgb(bgColor);
const colorRgb: number[] | undefined = extractRgb(color);

if (bgColor === undefined || colorRgb === undefined) {
// 0 being AA and AAA level friendly. We assume that if for some reason we can't extract color, we better not display a warning about it.
return 0;
}

const lumBg: number = this.calculateLuminance(bgRgb);
const lumColor: number = this.calculateLuminance(colorRgb);

return this.calculateColorContrastRatio(lumBg, lumColor);
}
}
Loading