Merge branch 'next-colors' into 'next'
Next: colors See merge request soapbox-pub/soapbox-fe!1126
This commit is contained in:
commit
8970e4e3db
|
@ -1 +0,0 @@
|
|||
<svg width="1754" height="1336" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><filter x="-18.1%" y="-15.3%" width="136.3%" height="130.7%" filterUnits="objectBoundingBox" id="c"><feGaussianBlur stdDeviation="50" in="SourceGraphic"/></filter><filter x="-16.5%" y="-11.7%" width="133%" height="123.3%" filterUnits="objectBoundingBox" id="d"><feGaussianBlur stdDeviation="50" in="SourceGraphic"/></filter><path id="a" d="M0 0h1754v1336H0z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path d="M1257.79 335.852C1262 527.117 897.55 530.28 792.32 977.19 600.48 981.41 435.29 545.31 431.08 354.046 426.871 162.782 578.976 4.31 770.815.088c191.844-4.222 482.764 144.5 486.974 335.764Z" fill="#E7F5FF" fill-rule="nonzero" filter="url(#c)" transform="translate(309.54 -367.538)"/><path d="M71.127 1126.654c206.164 179.412 502.452 211.232 661.777 71.072 159.325-140.163 295.165-510.155 8.23-504.412-320.079 6.405-381.35-817.422-540.675-677.258-31 368-335.497 931.182-129.332 1110.598Z" fill="#5448EE" fill-rule="nonzero" filter="url(#d)" transform="translate(309.54 -141.056)" opacity=".1"/></g></g></svg>
|
Before Width: | Height: | Size: 1.2 KiB |
|
@ -41,6 +41,47 @@ export const makeDefaultConfig = features => {
|
|||
banner: '',
|
||||
brandColor: '', // Empty
|
||||
accentColor: '',
|
||||
colors: ImmutableMap({
|
||||
gray: ImmutableMap({
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
}),
|
||||
success: ImmutableMap({
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
}),
|
||||
danger: ImmutableMap({
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}),
|
||||
'gradient-purple': '#b8a3f9',
|
||||
'gradient-blue': '#9bd5ff',
|
||||
'sea-blue': '#2feecc',
|
||||
}),
|
||||
customCss: ImmutableList(),
|
||||
promoPanel: ImmutableMap({
|
||||
items: ImmutableList(),
|
||||
|
@ -72,7 +113,8 @@ export const getSoapboxConfig = createSelector([
|
|||
state => state.get('soapbox'),
|
||||
state => getFeatures(state.get('instance')),
|
||||
], (soapbox, features) => {
|
||||
return makeDefaultConfig(features).merge(soapbox);
|
||||
const defaultConfig = makeDefaultConfig(features);
|
||||
return soapbox.mergeDeepWith((o, n) => o || n, defaultConfig);
|
||||
});
|
||||
|
||||
export function rememberSoapboxConfig(host) {
|
||||
|
|
|
@ -25,6 +25,7 @@ import { createGlobals } from 'soapbox/globals';
|
|||
import messages from 'soapbox/locales/messages';
|
||||
import { makeGetAccount } from 'soapbox/selectors';
|
||||
import SoapboxPropTypes from 'soapbox/utils/soapbox_prop_types';
|
||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||
|
||||
import { INTRODUCTION_VERSION } from '../actions/onboarding';
|
||||
import { preload } from '../actions/preload';
|
||||
|
@ -83,6 +84,7 @@ const mapStateToProps = (state) => {
|
|||
dyslexicFont: settings.get('dyslexicFont'),
|
||||
demetricator: settings.get('demetricator'),
|
||||
locale: validLocale(locale) ? locale : 'en',
|
||||
themeCss: generateThemeCss(soapboxConfig),
|
||||
brandColor: soapboxConfig.get('brandColor'),
|
||||
themeMode: settings.get('themeMode'),
|
||||
singleUserMode,
|
||||
|
@ -103,6 +105,7 @@ class SoapboxMount extends React.PureComponent {
|
|||
dyslexicFont: PropTypes.bool,
|
||||
demetricator: PropTypes.bool,
|
||||
locale: PropTypes.string.isRequired,
|
||||
themeCss: PropTypes.string,
|
||||
themeMode: PropTypes.string,
|
||||
brandColor: PropTypes.string,
|
||||
dispatch: PropTypes.func,
|
||||
|
@ -139,7 +142,7 @@ class SoapboxMount extends React.PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { me, account, instanceLoaded, locale, singleUserMode } = this.props;
|
||||
const { me, account, instanceLoaded, themeCss, locale, singleUserMode } = this.props;
|
||||
if (me === null) return null;
|
||||
if (me && !account) return null;
|
||||
if (!instanceLoaded) return null;
|
||||
|
@ -169,6 +172,7 @@ class SoapboxMount extends React.PureComponent {
|
|||
<Helmet>
|
||||
{/* <html lang='en' className={this.props.themeMode} /> */}
|
||||
<body className={bodyClass} />
|
||||
{themeCss && <style id='theme' type='text/css'>{`:root{${themeCss}}`}</style>}
|
||||
<meta name='theme-color' content={this.props.brandColor} />
|
||||
</Helmet>
|
||||
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
||||
|
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
||||
import { defaultSettings } from 'soapbox/actions/settings';
|
||||
import { themeColorsToCSS } from 'soapbox/utils/theme';
|
||||
import { generateThemeCss } from 'soapbox/utils/theme';
|
||||
|
||||
export default function SitePreview({ soapbox }) {
|
||||
|
||||
|
@ -18,7 +18,7 @@ export default function SitePreview({ soapbox }) {
|
|||
|
||||
return (
|
||||
<div className={bodyClass}>
|
||||
<style>{`.site-preview {${themeColorsToCSS(soapbox.get('brandColor'), soapbox.get('accentColor'))}}`}</style>
|
||||
<style>{`.site-preview {${generateThemeCss(soapbox)}}`}</style>
|
||||
<div className='app-holder'>
|
||||
<div>
|
||||
<div className='ui'>
|
||||
|
|
|
@ -1,10 +1,28 @@
|
|||
import React from 'react';
|
||||
import InlineSVG from 'react-inlinesvg';
|
||||
|
||||
export default () => (
|
||||
<div className='fixed top-0 inset-x-0 flex justify-center overflow-hidden pointer-events-none'>
|
||||
<div className='flex-none flex justify-center'>
|
||||
<InlineSVG src={require('../../../../images/bg-shape.svg')} className='flex-none max-w-none' />
|
||||
<svg width='1754' height='1336' xmlns='http://www.w3.org/2000/svg'>
|
||||
<defs>
|
||||
<filter x='-18.1%' y='-15.3%' width='136.3%' height='130.7%' filterUnits='objectBoundingBox' id='c'>
|
||||
<feGaussianBlur stdDeviation='50' in='SourceGraphic' />
|
||||
</filter>
|
||||
<filter x='-16.5%' y='-11.7%' width='133%' height='123.3%' filterUnits='objectBoundingBox' id='d'>
|
||||
<feGaussianBlur stdDeviation='50' in='SourceGraphic' />
|
||||
</filter>
|
||||
<path id='a' d='M0 0h1754v1336H0z' />
|
||||
</defs>
|
||||
<g fill='none' fill-rule='evenodd'>
|
||||
<mask id='b' fill='#fff'>
|
||||
<use xlinkHref='#a' />
|
||||
</mask>
|
||||
<g mask='url(#b)'>
|
||||
<path className='fill-bg-shape-1' d='M1257.79 335.852C1262 527.117 897.55 530.28 792.32 977.19 600.48 981.41 435.29 545.31 431.08 354.046 426.871 162.782 578.976 4.31 770.815.088c191.844-4.222 482.764 144.5 486.974 335.764Z' fill-rule='nonzero' filter='url(#c)' transform='translate(309.54 -367.538)' />
|
||||
<path className='fill-bg-shape-2' d='M71.127 1126.654c206.164 179.412 502.452 211.232 661.777 71.072 159.325-140.163 295.165-510.155 8.23-504.412-320.079 6.405-381.35-817.422-540.675-677.258-31 368-335.497 931.182-129.332 1110.598Z' fill-rule='nonzero' filter='url(#d)' transform='translate(309.54 -141.056)' opacity='.1' />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -33,11 +33,13 @@ const Navbar = () => {
|
|||
<nav className='bg-white shadow z-50 sticky top-0' ref={node}>
|
||||
<div className='max-w-7xl mx-auto px-2 sm:px-6 lg:px-8'>
|
||||
<div className='relative flex justify-between h-12 lg:h-16'>
|
||||
{account && (
|
||||
<div className='absolute inset-y-0 left-0 flex items-center lg:hidden'>
|
||||
<button onClick={onOpenSidebar}>
|
||||
<Avatar src={account.avatar} size={34} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classNames({
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
export type Rgb = { r: number, g: number, b: number };
|
||||
export type Hsl = { h: number, s: number, l: number };
|
||||
|
||||
export type TailwindColorObject = {
|
||||
[key: number]: string;
|
||||
};
|
||||
|
||||
export type TailwindColorPalette = {
|
||||
[key: string]: TailwindColorObject | string,
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import tintify from '../colors';
|
||||
|
||||
const AZURE = '#0482d8';
|
||||
|
||||
describe('tintify()', () => {
|
||||
it('generates tints from a base color', () => {
|
||||
const result = tintify(AZURE);
|
||||
|
||||
expect(result).toEqual({
|
||||
'100': '#e6f3fb',
|
||||
'200': '#c0e0f5',
|
||||
'300': '#9bcdef',
|
||||
'400': '#4fa8e4',
|
||||
'50': '#f2f9fd',
|
||||
'500': '#0482d8',
|
||||
'600': '#0475c2',
|
||||
'700': '#0362a2',
|
||||
'800': '#024e82',
|
||||
'900': '#02406a',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,215 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { toTailwind, fromLegacyColors, expandPalette } from '../tailwind';
|
||||
|
||||
describe('toTailwind()', () => {
|
||||
it('handles empty Soapbox config', () => {
|
||||
const soapboxConfig = ImmutableMap();
|
||||
const result = toTailwind(soapboxConfig);
|
||||
const expected = ImmutableMap({ colors: ImmutableMap() });
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('converts brandColor into a Tailwind color palette', () => {
|
||||
const soapboxConfig = ImmutableMap({ brandColor: '#0482d8' });
|
||||
|
||||
const expected = {
|
||||
brandColor: '#0482d8',
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f2f9fd',
|
||||
100: '#e6f3fb',
|
||||
200: '#c0e0f5',
|
||||
300: '#9bcdef',
|
||||
400: '#4fa8e4',
|
||||
500: '#0482d8',
|
||||
600: '#0475c2',
|
||||
700: '#0362a2',
|
||||
800: '#024e82',
|
||||
900: '#02406a',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = toTailwind(soapboxConfig);
|
||||
expect(result.toJS()).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it('prefers Tailwind colors object over legacy colors', () => {
|
||||
const soapboxConfig = ImmutableMap({
|
||||
brandColor: '#0482d8',
|
||||
colors: ImmutableMap({
|
||||
primary: ImmutableMap({
|
||||
300: '#ff0000',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const expected = {
|
||||
brandColor: '#0482d8',
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#f2f9fd',
|
||||
100: '#e6f3fb',
|
||||
200: '#c0e0f5',
|
||||
300: '#ff0000', // <--
|
||||
400: '#4fa8e4',
|
||||
500: '#0482d8',
|
||||
600: '#0475c2',
|
||||
700: '#0362a2',
|
||||
800: '#024e82',
|
||||
900: '#02406a',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = toTailwind(soapboxConfig);
|
||||
expect(result.toJS()).toMatchObject(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromLegacyColors()', () => {
|
||||
it('converts only brandColor', () => {
|
||||
const soapboxConfig = ImmutableMap({ brandColor: '#0482d8' });
|
||||
|
||||
const expected = {
|
||||
primary: {
|
||||
50: '#f2f9fd',
|
||||
100: '#e6f3fb',
|
||||
200: '#c0e0f5',
|
||||
300: '#9bcdef',
|
||||
400: '#4fa8e4',
|
||||
500: '#0482d8',
|
||||
600: '#0475c2',
|
||||
700: '#0362a2',
|
||||
800: '#024e82',
|
||||
900: '#02406a',
|
||||
},
|
||||
// Accent color is generated from brandColor
|
||||
accent: {
|
||||
50: '#f3fbfd',
|
||||
100: '#e7f7fa',
|
||||
200: '#c3ecf4',
|
||||
300: '#9fe1ed',
|
||||
400: '#58cadf',
|
||||
500: '#10b3d1',
|
||||
600: '#0ea1bc',
|
||||
700: '#0c869d',
|
||||
800: '#0a6b7d',
|
||||
900: '#085866',
|
||||
},
|
||||
};
|
||||
|
||||
const result = fromLegacyColors(soapboxConfig);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('converts both legacy colors', () => {
|
||||
const soapboxConfig = ImmutableMap({
|
||||
brandColor: '#0482d8',
|
||||
accentColor: '#2bd110',
|
||||
});
|
||||
|
||||
const expected = {
|
||||
primary: {
|
||||
50: '#f2f9fd',
|
||||
100: '#e6f3fb',
|
||||
200: '#c0e0f5',
|
||||
300: '#9bcdef',
|
||||
400: '#4fa8e4',
|
||||
500: '#0482d8',
|
||||
600: '#0475c2',
|
||||
700: '#0362a2',
|
||||
800: '#024e82',
|
||||
900: '#02406a',
|
||||
},
|
||||
accent: {
|
||||
50: '#f4fdf3',
|
||||
100: '#eafae7',
|
||||
200: '#caf4c3',
|
||||
300: '#aaed9f',
|
||||
400: '#6bdf58',
|
||||
500: '#2bd110',
|
||||
600: '#27bc0e',
|
||||
700: '#209d0c',
|
||||
800: '#1a7d0a',
|
||||
900: '#156608',
|
||||
},
|
||||
};
|
||||
|
||||
const result = fromLegacyColors(soapboxConfig);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandPalette()', () => {
|
||||
it('expands one color', () => {
|
||||
const palette = { primary: '#0482d8' };
|
||||
|
||||
const expected = {
|
||||
primary: {
|
||||
50: '#f2f9fd',
|
||||
100: '#e6f3fb',
|
||||
200: '#c0e0f5',
|
||||
300: '#9bcdef',
|
||||
400: '#4fa8e4',
|
||||
500: '#0482d8',
|
||||
600: '#0475c2',
|
||||
700: '#0362a2',
|
||||
800: '#024e82',
|
||||
900: '#02406a',
|
||||
},
|
||||
};
|
||||
|
||||
const result = expandPalette(palette);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('expands mixed palette', () => {
|
||||
const palette = {
|
||||
primary: {
|
||||
50: '#f2f9fd',
|
||||
100: '#e6f3fb',
|
||||
200: '#c0e0f5',
|
||||
300: '#9bcdef',
|
||||
400: '#4fa8e4',
|
||||
500: '#0482d8',
|
||||
600: '#0475c2',
|
||||
700: '#0362a2',
|
||||
800: '#024e82',
|
||||
900: '#02406a',
|
||||
},
|
||||
accent: '#2bd110',
|
||||
};
|
||||
|
||||
const expected = {
|
||||
primary: {
|
||||
50: '#f2f9fd',
|
||||
100: '#e6f3fb',
|
||||
200: '#c0e0f5',
|
||||
300: '#9bcdef',
|
||||
400: '#4fa8e4',
|
||||
500: '#0482d8',
|
||||
600: '#0475c2',
|
||||
700: '#0362a2',
|
||||
800: '#024e82',
|
||||
900: '#02406a',
|
||||
},
|
||||
accent: {
|
||||
50: '#f4fdf3',
|
||||
100: '#eafae7',
|
||||
200: '#caf4c3',
|
||||
300: '#aaed9f',
|
||||
400: '#6bdf58',
|
||||
500: '#2bd110',
|
||||
600: '#27bc0e',
|
||||
700: '#209d0c',
|
||||
800: '#1a7d0a',
|
||||
900: '#156608',
|
||||
},
|
||||
};
|
||||
|
||||
const result = expandPalette(palette);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Javis V. Pérez
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
*/
|
||||
|
||||
// Adapted from:
|
||||
// https://github.com/javisperez/tailwindcolorshades/blob/master/src/composables/colors.ts
|
||||
|
||||
import type { Rgb, TailwindColorObject } from 'soapbox/types/colors';
|
||||
|
||||
export function hexToRgb(hex: string): Rgb | null {
|
||||
const sanitizedHex = hex.replace(/##/g, '#');
|
||||
const colorParts = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(
|
||||
sanitizedHex,
|
||||
);
|
||||
|
||||
if (!colorParts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, r, g, b] = colorParts;
|
||||
|
||||
return {
|
||||
r: parseInt(r, 16),
|
||||
g: parseInt(g, 16),
|
||||
b: parseInt(b, 16),
|
||||
} as Rgb;
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
const toHex = (c: number) => `0${c.toString(16)}`.slice(-2);
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
}
|
||||
|
||||
export function getTextColor(color: string): '#FFF' | '#333' {
|
||||
const rgbColor = hexToRgb(color);
|
||||
|
||||
if (!rgbColor) {
|
||||
return '#333';
|
||||
}
|
||||
|
||||
const { r, g, b } = rgbColor;
|
||||
const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
|
||||
return luma < 120 ? '#FFF' : '#333';
|
||||
}
|
||||
|
||||
function lighten(hex: string, intensity: number): string {
|
||||
const color = hexToRgb(`#${hex}`);
|
||||
|
||||
if (!color) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const r = Math.round(color.r + (255 - color.r) * intensity);
|
||||
const g = Math.round(color.g + (255 - color.g) * intensity);
|
||||
const b = Math.round(color.b + (255 - color.b) * intensity);
|
||||
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
function darken(hex: string, intensity: number): string {
|
||||
const color = hexToRgb(hex);
|
||||
|
||||
if (!color) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const r = Math.round(color.r * intensity);
|
||||
const g = Math.round(color.g * intensity);
|
||||
const b = Math.round(color.b * intensity);
|
||||
|
||||
return rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
export default function(baseColor: string): TailwindColorObject {
|
||||
const response: TailwindColorObject = {
|
||||
500: `#${baseColor}`.replace(/##/g, '#'),
|
||||
};
|
||||
|
||||
const intensityMap: {
|
||||
[key: number]: number;
|
||||
} = {
|
||||
50: 0.95,
|
||||
100: 0.9,
|
||||
200: 0.75,
|
||||
300: 0.6,
|
||||
400: 0.3,
|
||||
600: 0.9,
|
||||
700: 0.75,
|
||||
800: 0.6,
|
||||
900: 0.49,
|
||||
};
|
||||
|
||||
[50, 100, 200, 300, 400].forEach(level => {
|
||||
response[level] = lighten(baseColor, intensityMap[level]);
|
||||
});
|
||||
|
||||
[600, 700, 800, 900].forEach(level => {
|
||||
response[level] = darken(baseColor, intensityMap[level]);
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||
|
||||
import tintify from 'soapbox/utils/colors';
|
||||
import { generateAccent } from 'soapbox/utils/theme';
|
||||
|
||||
import type { TailwindColorPalette } from 'soapbox/types/colors';
|
||||
|
||||
type SoapboxConfig = ImmutableMap<string, any>;
|
||||
type SoapboxColors = ImmutableMap<string, any>;
|
||||
|
||||
/** Check if the value is a valid hex color */
|
||||
const isHex = (value: any): boolean => /^#([0-9A-F]{3}){1,2}$/i.test(value);
|
||||
|
||||
/** Expand hex colors into tints */
|
||||
export const expandPalette = (palette: TailwindColorPalette): TailwindColorPalette => {
|
||||
// Generate palette only for present colors
|
||||
return Object.entries(palette).reduce((result: TailwindColorPalette, colorData) => {
|
||||
const [colorName, color] = colorData;
|
||||
|
||||
// Conditionally handle hex color and Tailwind color object
|
||||
if (typeof color === 'string' && isHex(color)) {
|
||||
result[colorName] = tintify(color);
|
||||
} else if (color && typeof color === 'object') {
|
||||
result[colorName] = color;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
};
|
||||
|
||||
// Generate accent color only if brandColor is present
|
||||
const maybeGenerateAccentColor = (brandColor: any): string | null => {
|
||||
return isHex(brandColor) ? generateAccent(brandColor) : null;
|
||||
};
|
||||
|
||||
/** Build a color object from legacy colors */
|
||||
export const fromLegacyColors = (soapboxConfig: SoapboxConfig): TailwindColorPalette => {
|
||||
const brandColor = soapboxConfig.get('brandColor');
|
||||
const accentColor = soapboxConfig.get('accentColor');
|
||||
|
||||
return expandPalette({
|
||||
primary: isHex(brandColor) ? brandColor : null,
|
||||
accent: isHex(accentColor) ? accentColor : maybeGenerateAccentColor(brandColor),
|
||||
});
|
||||
};
|
||||
|
||||
/** Convert Soapbox Config into Tailwind colors */
|
||||
export const toTailwind = (soapboxConfig: SoapboxConfig): SoapboxConfig => {
|
||||
const colors: SoapboxColors = ImmutableMap(soapboxConfig.get('colors'));
|
||||
const legacyColors: SoapboxColors = ImmutableMap(fromJS(fromLegacyColors(soapboxConfig)));
|
||||
|
||||
return soapboxConfig.set('colors', legacyColors.mergeDeep(colors));
|
||||
};
|
|
@ -1,102 +0,0 @@
|
|||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
export const generateThemeCss = (brandColor, accentColor) => {
|
||||
if (!brandColor) return null;
|
||||
return themeDataToCss(brandColorToThemeData(brandColor).merge(accentColorToThemeData(brandColor, accentColor)));
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/a/5624139
|
||||
function hexToRgb(hex) {
|
||||
// Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF")
|
||||
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
|
||||
hex = hex.replace(shorthandRegex, (m, r, g, b) => (
|
||||
r + r + g + g + b + b
|
||||
));
|
||||
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
} : {
|
||||
// fall back to Azure
|
||||
r: 4,
|
||||
g: 130,
|
||||
b: 216,
|
||||
};
|
||||
}
|
||||
|
||||
// Taken from chromatism.js
|
||||
// https://github.com/graypegg/chromatism/blob/master/src/conversions/rgb.js
|
||||
const rgbToHsl = value => {
|
||||
const r = value.r / 255;
|
||||
const g = value.g / 255;
|
||||
const b = value.b / 255;
|
||||
const rgbOrdered = [ r, g, b ].sort();
|
||||
const l = ((rgbOrdered[0] + rgbOrdered[2]) / 2) * 100;
|
||||
let s, h;
|
||||
if (rgbOrdered[0] === rgbOrdered[2]) {
|
||||
s = 0;
|
||||
h = 0;
|
||||
} else {
|
||||
if (l >= 50) {
|
||||
s = ((rgbOrdered[2] - rgbOrdered[0]) / ((2.0 - rgbOrdered[2]) - rgbOrdered[0])) * 100;
|
||||
} else {
|
||||
s = ((rgbOrdered[2] - rgbOrdered[0]) / (rgbOrdered[2] + rgbOrdered[0])) * 100;
|
||||
}
|
||||
if (rgbOrdered[2] === r) {
|
||||
h = ((g - b) / (rgbOrdered[2] - rgbOrdered[0])) * 60;
|
||||
} else if (rgbOrdered[2] === g) {
|
||||
h = (2 + ((b - r) / (rgbOrdered[2] - rgbOrdered[0]))) * 60;
|
||||
} else {
|
||||
h = (4 + ((r - g) / (rgbOrdered[2] - rgbOrdered[0]))) * 60;
|
||||
}
|
||||
if (h < 0) {
|
||||
h += 360;
|
||||
} else if (h > 360) {
|
||||
h = h % 360;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
h: h,
|
||||
s: s,
|
||||
l: l,
|
||||
};
|
||||
};
|
||||
|
||||
export const brandColorToThemeData = brandColor => {
|
||||
const { h, s, l } = rgbToHsl(hexToRgb(brandColor));
|
||||
return ImmutableMap({
|
||||
'brand-color_h': h,
|
||||
'brand-color_s': `${s}%`,
|
||||
'brand-color_l': `${l}%`,
|
||||
});
|
||||
};
|
||||
|
||||
export const accentColorToThemeData = (brandColor, accentColor) => {
|
||||
if (accentColor) {
|
||||
const { h, s, l } = rgbToHsl(hexToRgb(accentColor));
|
||||
|
||||
return ImmutableMap({
|
||||
'accent-color_h': h,
|
||||
'accent-color_s': `${s}%`,
|
||||
'accent-color_l': `${l}%`,
|
||||
});
|
||||
}
|
||||
|
||||
const { h } = rgbToHsl(hexToRgb(brandColor));
|
||||
return ImmutableMap({
|
||||
'accent-color_h': h - 15,
|
||||
'accent-color_s': '86%',
|
||||
'accent-color_l': '44%',
|
||||
});
|
||||
};
|
||||
|
||||
export const themeDataToCss = themeData => (
|
||||
themeData
|
||||
.entrySeq()
|
||||
.reduce((acc, cur) => acc + `--${cur[0]}:${cur[1]};`, '')
|
||||
);
|
||||
|
||||
export const themeColorsToCSS = (brandColor, accentColor) => themeDataToCss(brandColorToThemeData(brandColor).merge(accentColorToThemeData(brandColor, accentColor)));
|
|
@ -0,0 +1,98 @@
|
|||
import { hexToRgb } from './colors';
|
||||
import { toTailwind } from './tailwind';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
import type { Rgb, Hsl, TailwindColorPalette, TailwindColorObject } from 'soapbox/types/colors';
|
||||
|
||||
// Taken from chromatism.js
|
||||
// https://github.com/graypegg/chromatism/blob/master/src/conversions/rgb.js
|
||||
const rgbToHsl = (value: Rgb): Hsl => {
|
||||
const r = value.r / 255;
|
||||
const g = value.g / 255;
|
||||
const b = value.b / 255;
|
||||
const rgbOrdered = [ r, g, b ].sort();
|
||||
const l = ((rgbOrdered[0] + rgbOrdered[2]) / 2) * 100;
|
||||
let s, h;
|
||||
if (rgbOrdered[0] === rgbOrdered[2]) {
|
||||
s = 0;
|
||||
h = 0;
|
||||
} else {
|
||||
if (l >= 50) {
|
||||
s = ((rgbOrdered[2] - rgbOrdered[0]) / ((2.0 - rgbOrdered[2]) - rgbOrdered[0])) * 100;
|
||||
} else {
|
||||
s = ((rgbOrdered[2] - rgbOrdered[0]) / (rgbOrdered[2] + rgbOrdered[0])) * 100;
|
||||
}
|
||||
if (rgbOrdered[2] === r) {
|
||||
h = ((g - b) / (rgbOrdered[2] - rgbOrdered[0])) * 60;
|
||||
} else if (rgbOrdered[2] === g) {
|
||||
h = (2 + ((b - r) / (rgbOrdered[2] - rgbOrdered[0]))) * 60;
|
||||
} else {
|
||||
h = (4 + ((r - g) / (rgbOrdered[2] - rgbOrdered[0]))) * 60;
|
||||
}
|
||||
if (h < 0) {
|
||||
h += 360;
|
||||
} else if (h > 360) {
|
||||
h = h % 360;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
h: h,
|
||||
s: s,
|
||||
l: l,
|
||||
};
|
||||
};
|
||||
|
||||
// https://stackoverflow.com/a/44134328
|
||||
function hslToHex(color: Hsl): string {
|
||||
const { h, s } = color;
|
||||
let { l } = color;
|
||||
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - l) / 100;
|
||||
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0'); // convert to Hex and prefix "0" if needed
|
||||
};
|
||||
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
}
|
||||
|
||||
// Generate accent color from brand color
|
||||
export const generateAccent = (brandColor: string): string => {
|
||||
const { h } = rgbToHsl(hexToRgb(brandColor));
|
||||
return hslToHex({ h: h - 15, s: 86, l: 44 });
|
||||
};
|
||||
|
||||
const parseShades = (obj: Record<string, any>, color: string, shades: Record<string, any>) => {
|
||||
if (typeof shades === 'string') {
|
||||
const { r, g, b } = hexToRgb(shades);
|
||||
return obj[`--color-${color}`] = `${r} ${g} ${b}`;
|
||||
}
|
||||
|
||||
return Object.keys(shades).forEach(shade => {
|
||||
const { r, g, b } = hexToRgb(shades[shade]);
|
||||
obj[`--color-${color}-${shade}`] = `${r} ${g} ${b}`;
|
||||
});
|
||||
};
|
||||
|
||||
// Convert colors as CSS variables
|
||||
const parseColors = (colors: TailwindColorPalette): TailwindColorPalette => {
|
||||
return Object.keys(colors).reduce((obj, color) => {
|
||||
parseShades(obj, color, colors[color] as TailwindColorObject);
|
||||
return obj;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const colorsToCss = (colors: TailwindColorPalette): string => {
|
||||
const parsed = parseColors(colors);
|
||||
return Object.keys(parsed).reduce((css, variable) => {
|
||||
return css + `${variable}:${parsed[variable]};`;
|
||||
}, '');
|
||||
};
|
||||
|
||||
export const generateThemeCss = (soapboxConfig: ImmutableMap<string, any>): string => {
|
||||
return colorsToCss(toTailwind(soapboxConfig).get('colors').toJS() as TailwindColorPalette);
|
||||
};
|
|
@ -82,7 +82,7 @@ body,
|
|||
--input-border-color: #d1d5db;
|
||||
|
||||
// Typography
|
||||
--font-sans: 'Inter', Arial, sans-serif;
|
||||
--font-sans: 'Inter', arial, sans-serif;
|
||||
--font-weight-heading: 700;
|
||||
--font-weight-body: 400;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
module.exports = {
|
||||
'projects': [
|
||||
'<rootDir>/app/soapbox',
|
||||
],
|
||||
'testPathIgnorePatterns': [
|
||||
'<rootDir>/node_modules/',
|
||||
'<rootDir>/vendor/',
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
const { parseColorMatrix } = require('./tailwind/colors');
|
||||
|
||||
module.exports = {
|
||||
content: ['./app/**/*.{html,js,ts,tsx}'],
|
||||
darkMode: 'class',
|
||||
|
@ -31,63 +33,20 @@ module.exports = {
|
|||
'Noto Color Emoji',
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
gray: {
|
||||
50: '#f9fafb',
|
||||
100: '#f3f4f6',
|
||||
200: '#e5e7eb',
|
||||
300: '#d1d5db',
|
||||
400: '#9ca3af',
|
||||
500: '#6b7280',
|
||||
600: '#4b5563',
|
||||
700: '#374151',
|
||||
800: '#1f2937',
|
||||
900: '#111827',
|
||||
},
|
||||
primary: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#5448ee',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
accent: {
|
||||
300: '#ff5f87',
|
||||
500: '#ff4775',
|
||||
},
|
||||
'gradient-purple': '#b8a3f9',
|
||||
'gradient-blue': '#9bd5ff',
|
||||
'sea-blue': '#2feecc',
|
||||
},
|
||||
colors: parseColorMatrix({
|
||||
// Define color matrix (of available colors)
|
||||
// Colors are configured at runtime with CSS variables in soapbox.json
|
||||
gray: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
primary: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
success: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
danger: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
accent: [300, 500],
|
||||
'gradient-purple': true,
|
||||
'gradient-blue': true,
|
||||
'sea-blue': true,
|
||||
'bg-shape-1': true,
|
||||
'bg-shape-2': true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import {
|
||||
withOpacityValue,
|
||||
parseColorMatrix,
|
||||
} from '../colors';
|
||||
|
||||
describe('withOpacityValue()', () => {
|
||||
it('returns a Tailwind color function with alpha support', () => {
|
||||
const result = withOpacityValue('--color-primary-500');
|
||||
|
||||
// It returns a function
|
||||
expect(typeof result).toBe('function');
|
||||
|
||||
// Test calling the function
|
||||
expect(result({})).toBe('rgb(var(--color-primary-500))');
|
||||
expect(result({ opacityValue: .5 })).toBe('rgb(var(--color-primary-500) / 0.5)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseColorMatrix()', () => {
|
||||
it('returns a Tailwind color object', () => {
|
||||
const colorMatrix = {
|
||||
gray: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
primary: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
success: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
danger: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
accent: [300, 500],
|
||||
};
|
||||
|
||||
const result = parseColorMatrix(colorMatrix);
|
||||
|
||||
// Colors are mapped to functions which return CSS values
|
||||
expect(result.primary[500]({})).toEqual('rgb(var(--color-primary-500))');
|
||||
expect(result.accent[300]({ opacityValue: .3 })).toEqual('rgb(var(--color-accent-300) / 0.3)');
|
||||
});
|
||||
|
||||
it('parses single-tint values', () => {
|
||||
const colorMatrix = {
|
||||
gray: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
primary: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
success: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
danger: [50, 100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
accent: [300, 500],
|
||||
'gradient-purple': true,
|
||||
'gradient-blue': true,
|
||||
'sea-blue': true,
|
||||
};
|
||||
|
||||
const result = parseColorMatrix(colorMatrix);
|
||||
|
||||
expect(result['sea-blue']({})).toEqual('rgb(var(--color-sea-blue))');
|
||||
expect(result['gradient-purple']({ opacityValue: .7 })).toEqual('rgb(var(--color-gradient-purple) / 0.7)');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,46 @@
|
|||
// https://tailwindcss.com/docs/customizing-colors#using-css-variables
|
||||
function withOpacityValue(variable) {
|
||||
return ({ opacityValue }) => {
|
||||
if (opacityValue === undefined) {
|
||||
return `rgb(var(${variable}))`;
|
||||
}
|
||||
return `rgb(var(${variable}) / ${opacityValue})`;
|
||||
};
|
||||
}
|
||||
|
||||
// Parse a single color as a CSS variable
|
||||
const toColorVariable = (colorName, tint = null) => {
|
||||
const suffix = tint ? `-${tint}` : '';
|
||||
const variable = `--color-${colorName}${suffix}`;
|
||||
|
||||
return withOpacityValue(variable);
|
||||
};
|
||||
|
||||
// Parse list of tints into Tailwind function with CSS variables
|
||||
const parseTints = (colorName, tints) => {
|
||||
return tints.reduce((colorObj, tint) => {
|
||||
colorObj[tint] = toColorVariable(colorName, tint);
|
||||
return colorObj;
|
||||
}, {});
|
||||
};
|
||||
|
||||
// Parse color matrix into Tailwind color palette
|
||||
const parseColorMatrix = colorMatrix => {
|
||||
return Object.entries(colorMatrix).reduce((palette, colorData) => {
|
||||
const [colorName, tints] = colorData;
|
||||
|
||||
// Conditionally parse array or single-tint colors
|
||||
if (Array.isArray(tints)) {
|
||||
palette[colorName] = parseTints(colorName, tints);
|
||||
} else if (tints === true) {
|
||||
palette[colorName] = toColorVariable(colorName);
|
||||
}
|
||||
|
||||
return palette;
|
||||
}, {});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
withOpacityValue,
|
||||
parseColorMatrix,
|
||||
};
|
Loading…
Reference in New Issue