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

docs: interactive theme generator #508

Merged
merged 16 commits into from
Oct 31, 2023
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
133 changes: 133 additions & 0 deletions packages/components/.storybook/addons/theme-generator/Panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import React, { useState, useEffect, useCallback } from 'react';
import { AddonPanel, Form } from '@storybook/components';
import { PARAM_KEY, PANEL_DEFAULTS } from './constants';
import { useGlobals } from '@storybook/manager-api';
import { calculateColorsAsCss } from './colorCalculations';
import theme from '../../../../tokens/src/create-theme.cjs';

const { Textarea, Button } = Form;

interface PanelProps {
active: boolean;
}

export const Panel: React.FC<PanelProps> = props => {
const [useDefaultLuminanceMap, setUseDefaultLuminanceMap] = useState(PANEL_DEFAULTS.useDefaultLuminanceMap);

const [colors, setColors] = useState(PANEL_DEFAULTS.colors);

const [output, setOutput] = useState('');
const [globals, updateGlobals] = useGlobals();
const isActive = globals[PARAM_KEY] || false;

const [hexInputs, setHexInputs] = useState({
primary: PANEL_DEFAULTS.colors.primary,
accent: PANEL_DEFAULTS.colors.accent,
neutral: PANEL_DEFAULTS.colors.neutral
});

const useDebouncedEffect = (effect, delay, deps) => {
const callback = useCallback(effect, deps);

useEffect(() => {
const handler = setTimeout(() => {
callback();
}, delay);
return () => {
clearTimeout(handler);
};
}, [callback, delay]);
};

useEffect(() => {
setOutput(calculateColorsAsCss(colors, theme, useDefaultLuminanceMap));
}, [colors, useDefaultLuminanceMap]);

useDebouncedEffect(
() => {
const panelState = {
colors,
useDefaultLuminanceMap
};
updateGlobals({
[PARAM_KEY + '_STATE']: JSON.stringify(panelState)
});
},
500,
[colors, useDefaultLuminanceMap]
);

return (
<AddonPanel {...props}>
<div style={{ padding: '20px' }}>
<h2>Soid Theme Generator</h2>
{['primary', 'accent', 'neutral'].map(colorKey => (
<div style={{ display: 'flex', alignItems: 'center', marginTop: '8px' }}>
<label style={{ width: '60px', display: 'inline-block' }}>
{colorKey.charAt(0).toUpperCase() + colorKey.slice(1)}
</label>

{/* Color Picker */}
<input
type="color"
value={colors[colorKey]}
onChange={e => {
const newColor = e.target.value;
setColors(prev => ({ ...prev, [colorKey]: newColor }));
setHexInputs(prev => ({ ...prev, [colorKey]: newColor }));
}}
/>

{/* Text Input for Hex Color */}
<input
type="text"
value={hexInputs[colorKey]}
pattern="^#(?:[0-9a-fA-F]{3}){1,2}$"
placeholder="#RRGGBB"
onChange={e => {
const newHexValue = e.target.value;
setHexInputs(prev => ({ ...prev, [colorKey]: newHexValue }));

// Check if it's a valid hex color and update the main color state
if (/^#(?:[0-9a-fA-F]{3}){1,2}$/.test(newHexValue)) {
setColors(prev => ({ ...prev, [colorKey]: newHexValue }));
}
}}
style={{ marginLeft: '8px' }}
/>
</div>
))}

<div style={{ marginTop: '12px' }}>
<input
id="useDefaultLuminanceMap"
type="checkbox"
checked={useDefaultLuminanceMap}
onChange={e => setUseDefaultLuminanceMap(e.target.checked)}
/>
<label htmlFor="useDefaultLuminanceMap">
Normalize colors (This might improve your scale but could reduce accessibility – please check a11y
compliance yourself.)
</label>
</div>

<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<Button
onClick={() => {
updateGlobals({ [PARAM_KEY]: !isActive });
}}
primary={isActive}
>
{isActive ? '🟢 Disable Theme' : '⚪️ Enable Theme'}
</Button>
</div>

<Textarea
style={{ marginTop: '16px', fontFamily: 'monospace', width: '400px', height: '600px' }}
readOnly
value={output || ''}
/>
</div>
</AddonPanel>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import chroma from 'chroma-js';

const defaultLuminanceMap = {
50: 0.95,
100: 0.84,
200: 0.73,
300: 0.62,
400: 0.35,
500: 0.22,
550: 0.18,
600: 0.1,
700: 0.08,
800: 0.04,
DEFAULT: 0.22
};

const extractRGB = str => {
const match = str.match(/,\s*([\d\s]+)\s*\)/);
return match ? match[1] : null;
};

const calculateLuminanceMap = colorObject => {
let relevantColors = {
accent: { ...colorObject['accent'] },
primary: { ...colorObject['primary'] },
neutral: { ...colorObject['neutral'] }
};

let luminanceMaps = {};

for (let colorType in relevantColors) {
let luminanceMap = {};

for (let scale in colorObject[colorType]) {
const rgbStr = extractRGB(colorObject[colorType][scale]);

// If we successfully extracted the RGB string
if (rgbStr) {
const rgbArray = rgbStr.split(' ').map(num => parseInt(num, 10));
const luminance = chroma(...rgbArray).luminance();
luminanceMap[scale] = luminance;
}
}

luminanceMaps[colorType] = luminanceMap;
}

return luminanceMaps;
};

const gaussian = (x, stdDev = 1) => {
return Math.exp(-Math.pow(x, 2) / (2 * Math.pow(stdDev, 2)));
};

const findClosestLuminanceKey = (luminanceValue, luminanceMap) => {
const allKeys = Object.keys(luminanceMap).filter(key => key !== 'DEFAULT'); // Exclude the "DEFAULT" key

return allKeys.reduce((closest, key) => {
if (Math.abs(luminanceMap[closest] - luminanceValue) < Math.abs(luminanceMap[key] - luminanceValue)) {
return closest;
}
return key;
}, allKeys[0]); // Setting the initial value as the first key (excluding "DEFAULT")
};

// Adjusts the luminance map to make that the original color is always in use at some point.
// Colors close to the original color will be changed more than colors further away.
// E. g. if primary-500 fits best, primary-400 and primary-600 will be adjusted more than primary-300 and primary-700
// Aim is that the whole color palette is most consistent in itself using the original color
const adjustLuminanceMap = (color, luminanceMap) => {
const colorLuminance = chroma(color).luminance();
const closestLuminanceKey = findClosestLuminanceKey(colorLuminance, luminanceMap);
const closestLuminance = luminanceMap[closestLuminanceKey];

const difference = colorLuminance - closestLuminance;
let newLuminanceMap = {};

for (let key in luminanceMap) {
let luminanceDistance = closestLuminance - luminanceMap[key];
let impactFactor = gaussian(luminanceDistance, 0.5);
let adjustment = difference * impactFactor;

newLuminanceMap[key] = Math.min(1, Math.max(0, luminanceMap[key] + adjustment)); // Clamp between 0 and 1
}
return newLuminanceMap;
};

export const calculateColorsForType = (type, theme, colors, useDefaultLuminanceMap) => {
const color = colors[type];
const luminanceMaps = calculateLuminanceMap(theme['color']);

if (!color || !chroma.valid(color)) return '';

const hex = chroma(color).hex();
const scalesForType = Object.keys(luminanceMaps[type]);

const selectedLuminanceMap = useDefaultLuminanceMap ? defaultLuminanceMap : luminanceMaps[type];

const adjustedLuminanceMap = adjustLuminanceMap(color, selectedLuminanceMap);

const luminancesForType = scalesForType.map(scaleValue => {
return adjustedLuminanceMap[scaleValue];
});

const scale = chroma
.scale(luminancesForType.map(luminance => chroma(hex).luminance(luminance)))
.colors(scalesForType.length);

let tokens = '';
scale.forEach((currentColor, index) => {
const scaleValue = scalesForType[index];
const rgb = chroma(currentColor).rgb();
tokens += ` --sd-color-${type}${scaleValue !== 'DEFAULT' ? `-${scaleValue}` : ''}: ${rgb.join(' ')};\n`;
});

return tokens;
};

export const calculateColorsAsCss = (colors, theme, useDefaultLuminanceMap) => {
let allTokens = ':root{\n /* Copy & paste into your theme */\n';

Object.keys(colors).forEach(type => {
allTokens += calculateColorsForType(type, theme, colors, useDefaultLuminanceMap);
});

allTokens += '}';
return allTokens;
};
11 changes: 11 additions & 0 deletions packages/components/.storybook/addons/theme-generator/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export const ADDON_ID = 'solid/theme-generator';
export const PANEL_ID = `${ADDON_ID}/panel`;
export const PARAM_KEY = `solidThemeGenerator`;
export const PANEL_DEFAULTS = {
colors: {
primary: '#4bbce2',
accent: '#e24a89',
neutral: '#b0b0b0'
},
useDefaultLuminanceMap: false
};
14 changes: 14 additions & 0 deletions packages/components/.storybook/addons/theme-generator/manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { addons, types } from '@storybook/manager-api';
import { ADDON_ID, PANEL_ID } from './constants';
import { Panel } from './Panel';

// Register the addon
addons.register(ADDON_ID, () => {
// Register the panel
addons.add(PANEL_ID, {
type: types.PANEL,
title: '🎨 Theme',
match: ({ viewMode }) => viewMode === 'story',
render: Panel
});
});
12 changes: 12 additions & 0 deletions packages/components/.storybook/addons/theme-generator/preset.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
function previewAnnotations(entry = []) {
return [...entry, require.resolve('./preview')];
}

function managerEntries(entry = []) {
return [...entry, require.resolve('./manager')];
}

module.exports = {
managerEntries,
previewAnnotations
};
13 changes: 13 additions & 0 deletions packages/components/.storybook/addons/theme-generator/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Renderer, ProjectAnnotations } from '@storybook/types';
import { PARAM_KEY } from './constants';
import { withGlobals } from './withGlobals';

const preview: ProjectAnnotations<Renderer> = {
decorators: [withGlobals],
globals: {
[PARAM_KEY]: false,
[PARAM_KEY + '_STATE']: ''
}
};

export default preview;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { Renderer, PartialStoryFn as StoryFunction, StoryContext } from '@storybook/types';
import { useEffect, useGlobals } from '@storybook/preview-api';
import { PARAM_KEY, PANEL_DEFAULTS } from './constants';
import { calculateColorsAsCss } from './colorCalculations';
import theme from 'tailwind-theme';

export const withGlobals = (StoryFn: StoryFunction<Renderer>, context: StoryContext<Renderer>) => {
const [globals] = useGlobals();

const panelStateStr = globals[PARAM_KEY + '_STATE'];
const panelState = panelStateStr ? JSON.parse(panelStateStr) : null;

const customThemeActive = globals[PARAM_KEY];
let customTheme = calculateColorsAsCss(
panelState?.colors || PANEL_DEFAULTS.colors,
theme,
panelState?.useDefaultLuminanceMap || PANEL_DEFAULTS.useDefaultLuminanceMap
);

const isInDocs = context.viewMode === 'docs';

useEffect(() => {
const selector = isInDocs ? `#anchor--${context.id} .sb-story` : '#storybook-root';
displayToolState(selector, customThemeActive, customTheme);
}, [customThemeActive, customTheme]);

return StoryFn();
};

function displayToolState(selector: string, customThemeActive: boolean, customTheme: string) {
const styleTagId = 'dynamic-css-variables';
let styleTag = document.getElementById(styleTagId) as HTMLStyleElement;

// If customThemeActive is false, remove the style tag if it exists
if (!customThemeActive) {
if (styleTag) {
styleTag.remove();
}
return; // Exit early
}

// If customThemeActive is true, update or create the style tag
if (!styleTag) {
styleTag = document.createElement('style');
styleTag.id = styleTagId;
document.head.appendChild(styleTag);
}

styleTag.innerHTML = customTheme;
}
3 changes: 2 additions & 1 deletion packages/components/.storybook/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ module.exports = {
'@storybook/addon-mdx-gfm',
'@geometricpanda/storybook-addon-badges',
'@storybook/addon-actions',
'@storybook/addon-interactions'
'@storybook/addon-interactions',
'./addons/theme-generator/preset'
],
staticDirs: [
'./assets',
Expand Down
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"@web/test-runner-playwright": "^0.10.1",
"autoprefixer": "^10.4.14",
"cem-plugin-vs-code-custom-data-generator": "^1.4.1",
"chroma-js": "^2.4.2",
"chromatic": "^6.18.2",
"comment-parser": "^1.3.1",
"cssnano": "^6.0.1",
Expand Down
5 changes: 4 additions & 1 deletion packages/components/src/components/badge/badge.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ export const VariantAndInverted = {
},
args,
options: {
templateBackgrounds: { alternate: 'y', colors: ['#ECF0F9', '#00358E'] }
templateBackgrounds: {
alternate: 'y',
colors: ['', 'rgb(var(--sd-color-primary, 0 53 142))']
}
}
});
}
Expand Down
Loading
Loading