Sky text: bubble treatment instead of plates; Noonday ink toward black (replaces #49) #51
10 changed files with 116 additions and 147 deletions
|
|
@ -1,9 +1,7 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { SkyPlate } from '../../features/scene/skyPlate.css';
|
||||
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
|
||||
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
|
@ -25,7 +23,7 @@ export type RoomIntroProps = {
|
|||
room: Room;
|
||||
};
|
||||
|
||||
export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props }, ref) => {
|
||||
export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
|
@ -52,7 +50,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, className, ...props
|
|||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
|
||||
return (
|
||||
<Box direction="Column" grow="Yes" gap="500" className={classNames(SkyPlate, className)} {...props} ref={ref}>
|
||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||
<Box>
|
||||
<Avatar size="500">
|
||||
<RoomAvatar
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React, { RefObject, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
|
||||
import { SkyPlate } from '../scene/skyPlate.css';
|
||||
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { PrescreenControls } from './PrescreenControls';
|
||||
|
|
@ -164,7 +162,7 @@ export function CallView() {
|
|||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(ContainerColor({ variant: 'Surface' }), SkyPlate)}
|
||||
className={ContainerColor({ variant: 'Surface' })}
|
||||
style={{ minWidth: toRem(280) }}
|
||||
grow="Yes"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,69 +1,38 @@
|
|||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config } from 'folds';
|
||||
import { MessageBase, BubbleContent } from '../../components/message/layout/layout.css';
|
||||
import { PageContent, PageNavContent } from '../../components/page/style.css';
|
||||
import { Editor } from '../../components/editor/Editor.css';
|
||||
import { MembersDrawer } from '../room/MembersDrawer.css';
|
||||
import { CallStatus } from '../call-status/styles.css';
|
||||
import { RoomViewFollowing } from '../room/RoomViewFollowing.css';
|
||||
import { RoomViewTyping } from '../room/RoomViewTyping.css';
|
||||
import { SKY_PLATE_FALLBACK } from './skyPlate';
|
||||
import { globalStyle } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
import { CategoryButton } from '../room-nav/styles.css';
|
||||
import { surfaceInk } from './sceneShell.css';
|
||||
|
||||
// Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so text
|
||||
// outside a chat bubble would sit on bare sky, which a sky state can make light, dark or both
|
||||
// (dusk): no ink clears 4.5:1 on all of that (skyTheme.test.ts). Every such region is a plate
|
||||
// instead, the sign-in card's opaque --sky-bubble fill, with its own ink and link, so what was
|
||||
// checked for the card holds everywhere. One rule, below; a region joins it by class or by tag.
|
||||
// Over the scene (html[data-scene]) the signed-in shell is clear (sceneShell.css.ts), so app text
|
||||
// sits on sky. No pane and no slab is drawn behind it. Each run of text gets what a chat bubble
|
||||
// gets (message/layout/layout.css.ts): a backdrop blur and a tint of the theme's own surface at half
|
||||
// strength, translucent, so the sky still shows. The ink is the theme's own and is not changed.
|
||||
// What that reaches against each state's sky is pinned in skyTheme.test.ts; where it falls short the
|
||||
// sky bands have to move (the numbers are in the test), not this rule.
|
||||
const scene = ':root[data-scene]';
|
||||
const fill = `var(--sky-bubble, ${SKY_PLATE_FALLBACK.fill})`;
|
||||
const text = `var(--sky-bubble-text, ${SKY_PLATE_FALLBACK.text})`;
|
||||
const tint = `color-mix(in srgb, ${surfaceInk} 50%, transparent)`;
|
||||
|
||||
/** A region with no class of its own (the room intro, the call view) joins the plate with this. */
|
||||
export const SkyPlate = style({});
|
||||
// Leaf text in the shell. Menus and dialogs render outside #root with their own themed fill, and
|
||||
// buttons, fields and the composer keep their own surfaces, so they are left out; the room list's
|
||||
// section headings are buttons whose text is bare, so they are let back in.
|
||||
const leaf = ':is(span, p, a, h1, h2, h3, h4, h5, h6, label, small, strong, b, em, i, time, li)';
|
||||
const bare = ':not(:has(*:not(br, wbr)))';
|
||||
const outside = ':not(button *, [role="button"] *, [contenteditable] *, input, textarea)';
|
||||
|
||||
// A recipe's first class is its base style.
|
||||
const base = (recipe: () => string) => `.${recipe().split(' ')[0]}`;
|
||||
globalStyle(
|
||||
`${scene} #root ${leaf}${bare}${outside}, ${scene} #root .${CategoryButton} ${leaf}${bare}`,
|
||||
{
|
||||
backgroundColor: [surfaceInk, tint],
|
||||
backdropFilter: 'blur(12px)',
|
||||
padding: `0 ${config.space.S100}`,
|
||||
boxDecorationBreak: 'clone',
|
||||
WebkitBoxDecorationBreak: 'clone',
|
||||
}
|
||||
);
|
||||
|
||||
export const plateRegions = [
|
||||
`.${SkyPlate}`,
|
||||
// Title bars: the room header, the room list's header, every page header.
|
||||
'#root header',
|
||||
// The room list, and the pages that are lists (explore, inbox, search).
|
||||
`.${PageNavContent}`,
|
||||
`.${PageContent}`,
|
||||
// The composer, the members drawer, and the call status bar.
|
||||
`.${Editor}`,
|
||||
`.${MembersDrawer}`,
|
||||
`.${CallStatus}`,
|
||||
// The bars over the composer.
|
||||
`.${RoomViewTyping}`,
|
||||
base(RoomViewFollowing),
|
||||
// A message that is not a bubble (Modern, Compact) is plain text on the scene.
|
||||
`${base(MessageBase)}:not(:has(.${BubbleContent}))`,
|
||||
];
|
||||
|
||||
// Only inside #root: menus and dialogs render in #portalContainer with their own themed fill.
|
||||
const within = (ancestor: string) =>
|
||||
plateRegions.map((r) => `${ancestor} ${r.startsWith('#root') ? '' : '#root '}${r}`).join(', ');
|
||||
|
||||
globalStyle(within(scene), {
|
||||
backgroundColor: fill,
|
||||
color: text,
|
||||
// Themed ink was chosen for the clear shell; inside the plate every token reads the plate's.
|
||||
vars: {
|
||||
[color.Background.OnContainer]: text,
|
||||
[color.Surface.OnContainer]: text,
|
||||
[color.SurfaceVariant.OnContainer]: text,
|
||||
},
|
||||
});
|
||||
globalStyle(`${scene} #root header`, { borderRadius: config.radii.R400 });
|
||||
|
||||
// Links read against the plate. The Midnight and Noonday themes set their own --tc-link for it;
|
||||
// the other themes take the fallback, as the sign-in card does.
|
||||
globalStyle(within(`${scene} body:not(.gamecult-theme)`), {
|
||||
vars: { '--tc-link': SKY_PLATE_FALLBACK.link },
|
||||
});
|
||||
|
||||
globalStyle(`${scene} #root .${SkyPlate}`, {
|
||||
borderRadius: config.radii.R400,
|
||||
// A link keeps the text's ink and is underlined: the theme's link colour was chosen for a solid
|
||||
// ground and falls short on a translucent one (Midnight Sky 2.36 on its own sky).
|
||||
globalStyle(`${scene} #root a${bare}${outside}`, {
|
||||
color: 'inherit',
|
||||
textDecorationLine: 'underline',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
// The plate: the opaque surface text sits on wherever the shell is clear over the background
|
||||
// scene and a sky state can make the sky behind it anything. It is the sign-in card's colour
|
||||
// (--sky-bubble, set by the Midnight and Noonday themes in sky.css.ts), so what was checked for
|
||||
// the card holds here. The other themes take these fallbacks.
|
||||
export const SKY_PLATE_FALLBACK = {
|
||||
fill: '#0d1a26',
|
||||
text: '#b7c7d9',
|
||||
link: '#99c7ff',
|
||||
} as const;
|
||||
|
|
@ -182,6 +182,7 @@ describe('login ink against the sky backdrop', () => {
|
|||
'night|Light/Silver/Dark/Butter|text',
|
||||
'night|Light/Silver/Dark/Butter|link',
|
||||
'night|Midnight Sky|text',
|
||||
'day|Noonday Sky|text',
|
||||
]);
|
||||
describe.each(rows)('$theme $role in $state', (r) => {
|
||||
const holds = clears.has(`${r.state}|${r.theme}|${r.role}`);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { baked } from '../../../sky/baked';
|
||||
import { AA, contrast, luminance } from '../../../sky/skyTokens.js';
|
||||
import { Editor } from '../../components/editor/Editor.css';
|
||||
import { PageContent, PageNavContent } from '../../components/page/style.css';
|
||||
import { CallStatus } from '../call-status/styles.css';
|
||||
import { MembersDrawer } from '../room/MembersDrawer.css';
|
||||
import { RoomViewTyping } from '../room/RoomViewTyping.css';
|
||||
import { SKY_PLATE_FALLBACK } from './skyPlate';
|
||||
import { SkyPlate, plateRegions } from './skyPlate.css';
|
||||
import { AA, contrast, luminance, toHex } from '../../../sky/skyTokens.js';
|
||||
import { SKY_STATES, skyBackdrop, type SkyBackdrop, type SkyState } from './skyState';
|
||||
import { skyThemeId } from './skyTheme';
|
||||
|
||||
|
|
@ -46,12 +39,12 @@ describe('skyThemeId', () => {
|
|||
|
||||
describe('ink of the theme a state picks, on bare sky', () => {
|
||||
// Per state: the picked theme, its ink and link, and their ratios on bare sky, over the union
|
||||
// of the skies the scenes draw (skyBackdrop). Only night text passes (5.05); day's sky reaches
|
||||
// 0.919 and dusk's 0.017 to 0.965, so no theme's ink holds there. No link passes anywhere
|
||||
// (night 2.36): nothing rests on bare sky, everything sits on the plate below.
|
||||
// of the skies the scenes draw (skyBackdrop), strict whole range. Day (4.73) and night (5.05)
|
||||
// text pass; dusk (0.017 to 0.965) does not. The theme link never passes on bare sky (night
|
||||
// 2.36), so shell links wear the ink.
|
||||
const cells: [SkyState, string, string, string, string, string][] = [
|
||||
['day', 'noonday-sky', '#342d25', '3.71', '#2a4f27', '2.56'],
|
||||
['dusk', 'noonday-sky', '#342d25', '1.00', '#2a4f27', '1.00'],
|
||||
['day', 'noonday-sky', '#1f1a15', '4.73', '#2a4f27', '2.56'],
|
||||
['dusk', 'noonday-sky', '#1f1a15', '1.10', '#2a4f27', '1.00'],
|
||||
['night', 'midnight-sky', '#aeccff', '5.05', '#609385', '2.36'],
|
||||
];
|
||||
|
||||
|
|
@ -63,32 +56,71 @@ describe('ink of the theme a state picks, on bare sky', () => {
|
|||
expect(r2(ratio(link, skyBackdrop(state)))).toBe(wantLink);
|
||||
});
|
||||
|
||||
it('night is the only state whose text clears AA on bare sky', () => {
|
||||
it('day and night text clear AA on bare sky', () => {
|
||||
const passing = fixed.filter(
|
||||
([s, id]) => ratio(SKY[id].tokens.Background.OnContainer, skyBackdrop(s)) >= AA
|
||||
);
|
||||
expect(passing.map(([s]) => s)).toEqual(['night']);
|
||||
expect(passing.map(([s]) => s)).toEqual(['day', 'night']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the plate, where text cannot rest on bare sky', () => {
|
||||
// Each theme's plate: fill, ink, link and the WCAG ratios of ink and link on the fill.
|
||||
const plates = [
|
||||
['midnight-sky', baked.midnight, '12.17', '5.68'],
|
||||
['noonday-sky', baked.noonday, '6.70', '4.62'],
|
||||
describe('app text on the sky, with the bubble treatment (skyPlate.css.ts)', () => {
|
||||
// Shell text is the theme's own ink on a half-strength tint of the theme's own surface over the
|
||||
// sky, as a chat bubble is. The sky behind is a grey of the state's darkest or lightest
|
||||
// luminance (skyBackdrop); the blend is in sRGB, as the browser does it. Links wear the ink.
|
||||
const grey = (lum: number) => {
|
||||
let [lo, hi] = [0, 1];
|
||||
for (let i = 0; i < 40; i += 1) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (luminance(toHex([mid, mid, mid])) < lum) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return toHex([lo, lo, lo]);
|
||||
};
|
||||
const enc = (h: string) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
||||
const over = (tint: string, sky: string, a = 0.5) =>
|
||||
`#${enc(tint)
|
||||
.map((v, i) =>
|
||||
Math.round((v * a + enc(sky)[i] * (1 - a)) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
)
|
||||
.join('')}`;
|
||||
const worst = (ink: string, tint: string, b: SkyBackdrop) =>
|
||||
Math.min(
|
||||
...[b.darkest, b.lightest].map((l) => contrast(ink, over(tint, grey(l))))
|
||||
);
|
||||
|
||||
// [state, theme, ratio of ink on the tinted sky]: Day clears AA; Dusk falls short at its dark
|
||||
// end (0.017) and Nibu's sky bands have to move: darkest luminance needed is pinned below.
|
||||
const rows = [
|
||||
['day', 'noonday-sky', '7.68'],
|
||||
['dusk', 'noonday-sky', '4.08'],
|
||||
['night', 'midnight-sky', '8.07'],
|
||||
] as const;
|
||||
|
||||
it.each(plates)('%s: ink and link on the fill', (_id, sky, inkRatio, linkRatio) => {
|
||||
expect(r2(contrast(sky.bubbles.OtherText, sky.bubbles.Other))).toBe(inkRatio);
|
||||
expect(r2(contrast(sky.link, sky.bubbles.Other))).toBe(linkRatio);
|
||||
expect(contrast(sky.bubbles.OtherText, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA);
|
||||
expect(contrast(sky.link, sky.bubbles.Other)).toBeGreaterThanOrEqual(AA);
|
||||
it.each(rows)('%s with %s: ink on the tinted sky measures %s', (state, id, want) => {
|
||||
const sky = SKY[id];
|
||||
expect(r2(worst(sky.tokens.Background.OnContainer, sky.tokens.Surface.Container, skyBackdrop(state)))).toBe(want);
|
||||
});
|
||||
|
||||
it('the other themes take fallback colours that clear AA', () => {
|
||||
const { fill, text, link } = SKY_PLATE_FALLBACK;
|
||||
expect(r2(contrast(text, fill))).toBe('10.21');
|
||||
expect(r2(contrast(link, fill))).toBe('10.03');
|
||||
it('only dusk falls short, and by how much the dark end must lift', () => {
|
||||
const short = rows.filter(([s, id]) => {
|
||||
const sky = SKY[id];
|
||||
return worst(sky.tokens.Background.OnContainer, sky.tokens.Surface.Container, skyBackdrop(s)) < AA;
|
||||
});
|
||||
expect(short.map(([s]) => s)).toEqual(['dusk']);
|
||||
// Smallest darkest-sky luminance at which Noonday ink clears AA on the tinted sky.
|
||||
const sky = SKY['noonday-sky'];
|
||||
let [lo, hi] = [0, 1];
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
const mid = (lo + hi) / 2;
|
||||
const ok =
|
||||
contrast(sky.tokens.Background.OnContainer, over(sky.tokens.Surface.Container, grey(mid))) >= AA;
|
||||
if (ok) hi = mid;
|
||||
else lo = mid;
|
||||
}
|
||||
expect(r2(hi)).toBe('0.03');
|
||||
});
|
||||
|
||||
it('every container of the picked themes carries its text at AA', () => {
|
||||
|
|
@ -99,24 +131,3 @@ describe('the plate, where text cannot rest on bare sky', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('regions that sit on the plate', () => {
|
||||
// Every region whose text would otherwise rest on bare sky joins the one plate rule. The ink
|
||||
// and link of that plate are pinned above; this pins who is on it.
|
||||
const on = (cls: string) => expect(plateRegions).toContain(cls);
|
||||
|
||||
it('plates the composer, the members drawer, the call status bar and the call view', () => {
|
||||
on(`.${Editor}`);
|
||||
on(`.${MembersDrawer}`);
|
||||
on(`.${CallStatus}`);
|
||||
on(`.${SkyPlate}`);
|
||||
});
|
||||
|
||||
it('plates the room list, list pages, typing and Following bars and every header', () => {
|
||||
on(`.${PageNavContent}`);
|
||||
on(`.${PageContent}`);
|
||||
on(`.${RoomViewTyping}`);
|
||||
on('#root header');
|
||||
expect(plateRegions.some((r) => r.includes(':not(:has('))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
// would have had (the user's choice when signed in, Midnight Sky when signed out).
|
||||
//
|
||||
// Day and Dusk take the dark ink of Noonday Sky, Night the light ink of Midnight Sky. On bare sky
|
||||
// only Night's text clears 4.5:1 (5.05); Day's sky reaches 0.919 luminance and Dusk's spans
|
||||
// 0.017 to 0.965, so no ink holds there. Text therefore never rests on bare sky: every region
|
||||
// that would is a plate (skyPlate.css.ts), and skyTheme.test.ts pins the numbers.
|
||||
// Day (4.73) and Night (5.05) text clear 4.5:1 over the whole of their skies; Dusk (0.017 to 0.965)
|
||||
// does not (1.10), and its text relies on the bubble treatment of skyPlate.css.ts, which still falls
|
||||
// short at Dusk's dark end (skyTheme.test.ts pins the numbers and the luminance the band must reach).
|
||||
import type { SkyState } from './skyState';
|
||||
|
||||
export type SkyThemeId = 'midnight-sky' | 'noonday-sky';
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from '../../colors.css';
|
||||
import '../../gamecult.css';
|
||||
import '../../sky.css';
|
||||
import '../features/scene/skyPlate.css';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { skyThemeId } from '../features/scene/skyTheme';
|
||||
|
|
|
|||
|
|
@ -138,21 +138,21 @@ export const baked = {
|
|||
ContainerHover: '#b6b3ac',
|
||||
ContainerActive: '#b0ada6',
|
||||
ContainerLine: '#a6a39d',
|
||||
OnContainer: '#342d25',
|
||||
OnContainer: '#1f1a15',
|
||||
},
|
||||
Surface: {
|
||||
Container: '#d7d4cb',
|
||||
ContainerHover: '#d0cdc5',
|
||||
ContainerActive: '#c9c6be',
|
||||
ContainerLine: '#bebbb4',
|
||||
OnContainer: '#342d25',
|
||||
OnContainer: '#1f1a15',
|
||||
},
|
||||
SurfaceVariant: {
|
||||
Container: '#cac7bf',
|
||||
ContainerHover: '#c4c0b9',
|
||||
ContainerActive: '#bdbab2',
|
||||
ContainerLine: '#b3b0a9',
|
||||
OnContainer: '#342d25',
|
||||
OnContainer: '#1f1a15',
|
||||
},
|
||||
Primary: {
|
||||
Main: '#335d2f',
|
||||
|
|
@ -164,7 +164,7 @@ export const baked = {
|
|||
ContainerHover: '#b3b5a9',
|
||||
ContainerActive: '#adafa3',
|
||||
ContainerLine: '#a4a59a',
|
||||
OnContainer: '#342d25',
|
||||
OnContainer: '#1f1a15',
|
||||
},
|
||||
Secondary: {
|
||||
Main: '#392d23',
|
||||
|
|
@ -176,7 +176,7 @@ export const baked = {
|
|||
ContainerHover: '#b4b0a8',
|
||||
ContainerActive: '#adaaa2',
|
||||
ContainerLine: '#a4a09a',
|
||||
OnContainer: '#342d25',
|
||||
OnContainer: '#1f1a15',
|
||||
},
|
||||
Success: {
|
||||
Main: '#335d2f',
|
||||
|
|
@ -188,7 +188,7 @@ export const baked = {
|
|||
ContainerHover: '#b3b5a9',
|
||||
ContainerActive: '#adafa3',
|
||||
ContainerLine: '#a4a59a',
|
||||
OnContainer: '#342d25',
|
||||
OnContainer: '#1f1a15',
|
||||
},
|
||||
Warning: {
|
||||
Main: '#776530',
|
||||
|
|
@ -200,7 +200,7 @@ export const baked = {
|
|||
ContainerHover: '#beb7a9',
|
||||
ContainerActive: '#b7b1a4',
|
||||
ContainerLine: '#ada79b',
|
||||
OnContainer: '#342d25',
|
||||
OnContainer: '#1f1a15',
|
||||
},
|
||||
Critical: {
|
||||
Main: '#7a4238',
|
||||
|
|
@ -212,14 +212,14 @@ export const baked = {
|
|||
ContainerHover: '#bdb2aa',
|
||||
ContainerActive: '#b6aba4',
|
||||
ContainerLine: '#aca29b',
|
||||
OnContainer: '#342d25',
|
||||
OnContainer: '#1f1a15',
|
||||
},
|
||||
},
|
||||
bubbles: {
|
||||
Other: '#b9b6af',
|
||||
OtherText: '#342d25',
|
||||
OtherText: '#1f1a15',
|
||||
Own: '#acb99a',
|
||||
OwnText: '#342d25',
|
||||
OwnText: '#1f1a15',
|
||||
},
|
||||
userColors: [
|
||||
'#2b4675',
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ const LIGHT = {
|
|||
skyKey: 0.55,
|
||||
main: 0.55,
|
||||
container: 1.0,
|
||||
text: 1.0,
|
||||
text: 0.4, // ink toward black: bare Day text must clear AA on the Day sky (0.238 and up)
|
||||
steps: { Hover: 0.93, Active: 0.86, Line: 0.76 },
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue