diff --git a/code/core/src/components/components/Badge/Badge.tsx b/code/core/src/components/components/Badge/Badge.tsx index 038af02e8897..b6ffe656dded 100644 --- a/code/core/src/components/components/Badge/Badge.tsx +++ b/code/core/src/components/components/Badge/Badge.tsx @@ -31,45 +31,37 @@ const BadgeWrapper = styled.div( switch (status) { case 'critical': { return { - color: theme.color.critical, - background: theme.background.critical, + color: theme.fgColor.critical, + background: theme.bgColor.critical, + boxShadow: `inset 0 0 0 1px ${theme.borderColor.critical}`, }; } case 'negative': { return { - color: theme.color.negativeText, - background: theme.background.negative, - boxShadow: - theme.base === 'light' - ? `inset 0 0 0 1px ${transparentize(0.9, theme.color.negativeText)}` - : 'none', + color: theme.fgColor.negative, + background: theme.bgColor.negative, + boxShadow: `inset 0 0 0 1px ${theme.borderColor.negative}`, }; } case 'warning': { return { - color: theme.color.warningText, - background: theme.background.warning, - boxShadow: - theme.base === 'light' - ? `inset 0 0 0 1px ${transparentize(0.9, theme.color.warningText)}` - : 'none', + color: theme.fgColor.warning, + background: theme.bgColor.warning, + boxShadow: `inset 0 0 0 1px ${theme.borderColor.warning}`, }; } case 'neutral': { return { - color: theme.textMutedColor, - background: theme.base === 'light' ? theme.background.app : theme.barBg, + color: theme.fgColor.muted, + background: theme.base === 'dark' ? theme.barBg : theme.background.app, boxShadow: `inset 0 0 0 1px ${transparentize(0.8, theme.textMutedColor)}`, }; } case 'positive': { return { - color: theme.color.positiveText, - background: theme.background.positive, - boxShadow: - theme.base === 'light' - ? `inset 0 0 0 1px ${transparentize(0.9, theme.color.positiveText)}` - : 'none', + color: theme.fgColor.positive, + background: theme.bgColor.positive, + boxShadow: `inset 0 0 0 1px ${theme.borderColor.positive}`, }; } case 'active': { diff --git a/code/core/src/manager/components/sidebar/SearchResults.tsx b/code/core/src/manager/components/sidebar/SearchResults.tsx index d349406c55cf..91256cd50cd6 100644 --- a/code/core/src/manager/components/sidebar/SearchResults.tsx +++ b/code/core/src/manager/components/sidebar/SearchResults.tsx @@ -10,10 +10,10 @@ import { TrashIcon } from '@storybook/icons'; import type { ControllerStateAndHelpers } from 'downshift'; import { transparentize } from 'polished'; import { useStorybookApi } from 'storybook/manager-api'; -import { styled } from 'storybook/theming'; +import { styled, useTheme } from 'storybook/theming'; import { matchesKeyCode, matchesModifiers } from '../../keybinding'; -import { statusMapping } from '../../utils/status'; +import { getStatus } from '../../utils/status'; import { UseSymbol } from './IconSymbols'; import { NoResults } from './NoResults'; import { StatusLabel } from './StatusButton'; @@ -158,6 +158,7 @@ const Result: FC< isHighlighted: boolean; } & React.DetailedHTMLProps, HTMLLIElement> > = React.memo(function Result({ item, matches, onClick, ...props }) { + const theme = useTheme(); const click: MouseEventHandler = useCallback( (event) => { event.preventDefault(); @@ -171,12 +172,12 @@ const Result: FC< if (api && props.isHighlighted && item.type === 'component') { api.emit(PRELOAD_ENTRIES, { ids: [item.children[0]] }, { options: { target: item.refId } }); } - }, [props.isHighlighted, item]); + }, [api, props.isHighlighted, item]); const nameMatch = matches.find((match: Match) => match.key === 'name'); const pathMatches = matches.filter((match: Match) => match.key === 'path'); - const [icon] = item.status ? statusMapping[item.status] : []; + const [icon] = item.status ? getStatus(theme, item.status) : []; return ( diff --git a/code/core/src/manager/components/sidebar/Tree.tsx b/code/core/src/manager/components/sidebar/Tree.tsx index a011c18b8312..0d0862c72943 100644 --- a/code/core/src/manager/components/sidebar/Tree.tsx +++ b/code/core/src/manager/components/sidebar/Tree.tsx @@ -34,7 +34,7 @@ import { styled, useTheme } from 'storybook/theming'; import type { Link } from '../../../components/components/tooltip/TooltipLinkList'; import { MEDIA_DESKTOP_BREAKPOINT } from '../../constants'; -import { getGroupStatus, getMostCriticalStatusValue, statusMapping } from '../../utils/status'; +import { getGroupStatus, getMostCriticalStatusValue, getStatus } from '../../utils/status'; import { createId, getAncestorIds, @@ -214,6 +214,7 @@ const Node = React.memo(function Node(props) { onSelectStoryId, api, } = props; + const theme = useTheme(); const { isDesktop, isMobile, setMobileMenuOpen } = useLayout(); const { counts, statusesByValue } = useStatusSummary(item); @@ -282,6 +283,71 @@ const Node = React.memo(function Node(props) { ? useContextMenu(item, statusLinks, api) : { node: null, onMouseEnter: () => {} }; + if ( + (item.type === 'story' && + !('children' in item && item.children) && + (!('subtype' in item) || item.subtype !== 'test')) || + item.type === 'docs' + ) { + const LeafNode = item.type === 'docs' ? DocumentNode : StoryNode; + + const statusValue = getMostCriticalStatusValue( + Object.values(statuses || {}).map((s) => s.value) + ); + const [icon, textColor] = getStatus(theme, statusValue); + + return ( + + { + event.preventDefault(); + onSelectStoryId(item.id); + + if (isMobile) { + setMobileMenuOpen(false); + } + }} + {...(item.type === 'docs' && { docsMode })} + > + {(item.renderLabel as (i: typeof item, api: API) => React.ReactNode)?.(item, api) || + item.name} + + {isSelected && ( + + Skip to canvas + + )} + {contextMenu.node} + {icon ? ( + + {icon} + + ) : null} + + ); + } + if (item.type === 'root') { return ( (function Node(props) { } const itemStatus = getMostCriticalStatusValue(Object.values(statuses || {}).map((s) => s.value)); - const [itemIcon, itemColor] = statusMapping[itemStatus]; + const [itemIcon, itemColor] = getStatus(theme, itemStatus); const itemStatusButton = itemIcon ? ( (function Node(props) { const { children = [] } = item; const BranchNode = { component: ComponentNode, group: GroupNode, story: StoryNode }[item.type]; const status = getMostCriticalStatusValue([itemStatus, groupStatus?.[item.id]]); - const color = status ? statusMapping[status][1] : null; + const color = status ? getStatus(theme, status)[1] : null; const showBranchStatus = status === 'status-value:error' || status === 'status-value:warning'; return ( @@ -454,7 +520,6 @@ const Node = React.memo(function Node(props) { setMobileMenuOpen(false); } }} - {...(item.type === 'docs' && { docsMode })} > {(item.renderLabel as (i: typeof item, api: API) => React.ReactNode)?.(item, api) || item.name} diff --git a/code/core/src/manager/globals/exports.ts b/code/core/src/manager/globals/exports.ts index 044da1a20654..ec915c414def 100644 --- a/code/core/src/manager/globals/exports.ts +++ b/code/core/src/manager/globals/exports.ts @@ -381,6 +381,7 @@ export default { 'lighten', 'styled', 'themes', + 'tokens', 'typography', 'useTheme', 'withTheme', diff --git a/code/core/src/manager/utils/status.tsx b/code/core/src/manager/utils/status.tsx index 9d545d7a9701..7a0d1179de53 100644 --- a/code/core/src/manager/utils/status.tsx +++ b/code/core/src/manager/utils/status.tsx @@ -6,7 +6,8 @@ import { type API_HashEntry, type StatusesByStoryIdAndTypeId } from 'storybook/i import { CircleIcon } from '@storybook/icons'; -import { styled } from 'storybook/theming'; +import memoizerific from 'memoizerific'; +import { type Theme, styled } from 'storybook/theming'; import { UseSymbol } from '../components/sidebar/IconSymbols'; import { getDescendantIds } from './tree'; @@ -31,28 +32,35 @@ export const statusPriority: StatusValue[] = [ 'status-value:warning', 'status-value:error', ]; -export const statusMapping: Record = { - ['status-value:unknown']: [null, null], - ['status-value:pending']: [, 'currentColor'], - ['status-value:success']: [ - - - , - 'currentColor', - ], - ['status-value:warning']: [ - - - , - '#A15C20', - ], - ['status-value:error']: [ - - - , - '#D43900', - ], -}; + +// We might not want to make this a hook because it is used in the Tree after multiple returns. +// There could be scenarios where creating a story changes the type of an item (e.g. story now +// has children because it has a test child), so we could end up with rule of hooks violations. +export const getStatus = memoizerific(5)((theme: Theme, status: StatusValue) => { + const statusMapping: Record = { + ['status-value:unknown']: [null, null], + ['status-value:pending']: [, 'currentColor'], + ['status-value:success']: [ + + + , + 'currentColor', + ], + ['status-value:warning']: [ + + + , + theme.fgColor.warning, + ], + ['status-value:error']: [ + + + , + theme.fgColor.negative, + ], + }; + return statusMapping[status]; +}); export const getMostCriticalStatusValue = (statusValues: StatusValue[]): StatusValue => { return statusPriority.reduce( diff --git a/code/core/src/theming/base.ts b/code/core/src/theming/base.ts index 07276bed1783..676cb3f94707 100644 --- a/code/core/src/theming/base.ts +++ b/code/core/src/theming/base.ts @@ -25,7 +25,7 @@ export const color = { darkest: '#2E3338', // For borders - border: 'hsla(212, 50%, 30%, 0.15)', + border: 'hsl(212 50% 30% / 0.15)', // Status positive: '#66BF3C', @@ -100,3 +100,68 @@ export const typography = { code: 90, }, }; + +export const tokens = { + light: { + fgColor: { + default: color.darkest, + muted: color.dark, + accent: color.secondary, + inverse: color.lightest, + // TODO: add 'disabled' + positive: '#427C27', + warning: '#7A4100', + negative: '#C23400', + critical: '#FFFFFF', + }, + bgColor: { + default: color.lightest, + muted: background.app, + // TODO: add 'accent'? white or blue? + positive: '#F1FFEB', + warning: '#FFF7EB', + negative: '#FFF0EB', + critical: '#D13800', + }, + borderColor: { + default: color.border, + muted: 'hsl(0 0% 0% / 0.1)', + inverse: 'hsl(0 0% 100% / 0.1)', + positive: '#BFE7AC', + warning: '#FFCE85', + negative: '#FFC3AD', + critical: 'hsl(16 100% 100% / 0)', + }, + }, + dark: { + fgColor: { + default: '#C9CCCF', + muted: '#95999D', + accent: '#479DFF', + inverse: '#1B1C1D', + // TODO: add 'disabled' + positive: '#86CE64', + warning: '#FFAD33', + negative: '#FF6933', + critical: '#FF6933', + }, + bgColor: { + default: '#222325', + muted: '#1B1C1D', + // TODO: add 'accent'? white or blue? + positive: 'hsl(101 100% 100% / 0)', + warning: 'hsl(101 100% 100% / 0)', + negative: 'hsl(101 100% 100% / 0)', + critical: 'hsl(101 100% 100% / 0)', + }, + borderColor: { + default: 'hsl(0 0% 100% / 0.1)', + muted: 'hsl(0 0% 100% / 0.5)', + inverse: 'hsl(0 0% 0% / 0.1)', + positive: 'hsl(101 52% 64% / 0.15)', + warning: 'hsl(36 100% 64% / 0.15)', + negative: 'hsl(16 100% 64% / 0.15)', + critical: '#FF6933', + }, + }, +}; diff --git a/code/core/src/theming/convert.ts b/code/core/src/theming/convert.ts index 8cc9210ce052..b1b3c7f14449 100644 --- a/code/core/src/theming/convert.ts +++ b/code/core/src/theming/convert.ts @@ -1,7 +1,7 @@ import { opacify } from 'polished'; import { animation, easing } from './animation'; -import { background, color, typography } from './base'; +import { background, color, tokens, typography } from './base'; import { themes } from './create'; import { chromeDark, chromeLight, create as createSyntax } from './modules/syntax'; import type { Color, StorybookTheme, ThemeVars, ThemeVarsColors } from './types'; @@ -114,6 +114,9 @@ export const convert = (inherit: ThemeVars = themes[getPreferredColorScheme()]): ...rest, base, + + ...(base === 'dark' ? tokens.dark : tokens.light), + color: createColors(inherit), background: { app: appBg, @@ -127,6 +130,7 @@ export const convert = (inherit: ThemeVars = themes[getPreferredColorScheme()]): warning: background.warning, critical: background.critical, }, + typography: { fonts: { base: fontBase, @@ -175,14 +179,14 @@ export const convert = (inherit: ThemeVars = themes[getPreferredColorScheme()]): }, code: createSyntax({ - colors: base === 'light' ? lightSyntaxColors : darkSyntaxColors, + colors: base === 'dark' ? darkSyntaxColors : lightSyntaxColors, mono: fontCode, }), // Addon actions theme // API example https://github.com/storybookjs/react-inspector/blob/master/src/styles/themes/chromeLight.tsx addonActionsTheme: { - ...(base === 'light' ? chromeLight : chromeDark), + ...(base === 'dark' ? chromeDark : chromeLight), BASE_FONT_FAMILY: fontCode, BASE_FONT_SIZE: typography.size.s2 - 1, diff --git a/code/core/src/theming/create.ts b/code/core/src/theming/create.ts index 8a33d0dce577..5167fa191818 100644 --- a/code/core/src/theming/create.ts +++ b/code/core/src/theming/create.ts @@ -23,22 +23,28 @@ export const themes: Themes = { }; interface Rest { - [key: string]: any; + [key: string]: unknown; } export const create = ( - vars: ThemeVarsPartial = { base: preferredColorScheme }, + vars: ThemeVarsPartial = { + base: preferredColorScheme, + }, rest?: Rest ): ThemeVars => { const inherit: ThemeVars = { + // We always inherit the preferred color scheme. ...themes[preferredColorScheme], + // And then the declared theme base if it exists. ...(themes[vars.base] || {}), + // And then the actual theme content. ...vars, - ...{ base: themes[vars.base] ? vars.base : preferredColorScheme }, + // If no theme base was declared, we declare the preferred color scheme as the base. + base: themes[vars.base] ? vars.base : preferredColorScheme, }; return { ...rest, ...inherit, - ...{ barSelectedColor: vars.barSelectedColor || inherit.colorSecondary }, + barSelectedColor: vars.barSelectedColor || inherit.colorSecondary, }; }; diff --git a/code/core/src/theming/tests/convert.test.js b/code/core/src/theming/tests/convert.test.js index 6cfa924323a7..72955e843129 100644 --- a/code/core/src/theming/tests/convert.test.js +++ b/code/core/src/theming/tests/convert.test.js @@ -18,7 +18,7 @@ describe('convert', () => { expect(result).toMatchObject({ color: expect.objectContaining({ ancillary: '#22a699', - border: 'hsla(212, 50%, 30%, 0.15)', + border: 'hsl(212 50% 30% / 0.15)', critical: '#FFFFFF', dark: '#5C6570', darker: '#454C54', @@ -68,7 +68,7 @@ describe('convert', () => { expect(result).toMatchObject({ color: expect.objectContaining({ ancillary: '#22a699', - border: 'hsla(212, 50%, 30%, 0.15)', + border: 'hsl(212 50% 30% / 0.15)', critical: '#FFFFFF', dark: '#5C6570', darker: '#454C54', diff --git a/code/core/src/theming/themes/dark.ts b/code/core/src/theming/themes/dark.ts index 7748f69dd30b..e2aa337335d0 100644 --- a/code/core/src/theming/themes/dark.ts +++ b/code/core/src/theming/themes/dark.ts @@ -13,7 +13,7 @@ const theme: ThemeVars = { appContentBg: '#222325', appHoverBg: '#233952', appPreviewBg: color.lightest, - appBorderColor: 'rgba(255,255,255,.1)', + appBorderColor: 'hsl(0 0% 100% / 0.1)', appBorderRadius: 4, // Fonts @@ -33,11 +33,11 @@ const theme: ThemeVars = { // Form colors buttonBg: '#1B1C1D', - buttonBorder: 'hsl(0 0 100 / 0.1)', + buttonBorder: 'hsl(0 0% 100% / 0.1)', booleanBg: '#1B1C1D', booleanSelectedBg: '#292B2E', inputBg: '#1B1C1D', - inputBorder: 'hsl(0 0 100 / 0.1)', + inputBorder: 'hsl(0 0% 100% / 0.1)', inputTextColor: '#C9CCCF', inputBorderRadius: 4, }; diff --git a/code/core/src/theming/types.ts b/code/core/src/theming/types.ts index f009df790df8..9a58a3ed2a29 100644 --- a/code/core/src/theming/types.ts +++ b/code/core/src/theming/types.ts @@ -1,5 +1,5 @@ import type { animation, easing } from './animation'; -import type { background, color, typography } from './base'; +import type { background, color, tokens, typography } from './base'; export interface ThemeVars extends ThemeVarsBase, ThemeVarsColors {} @@ -70,6 +70,11 @@ export interface Brand { export interface StorybookTheme { color: Color; + + fgColor: typeof tokens.light.fgColor; + bgColor: typeof tokens.light.bgColor; + borderColor: typeof tokens.light.borderColor; + background: Background; typography: Typography; animation: Animation;