@jeremyckahn/farmhand
Version:
A farming game
438 lines (423 loc) • 13.3 kB
text/typescript
import { darken, Theme } from '@mui/material/styles/index.js'
import { createElement } from 'react'
import createTheme from '@mui/material/styles/createTheme.js'
import type { Shadows } from '@mui/material/styles/shadows.js'
import {
ArrowDropDownIcon,
CheckBoxIcon,
CheckBoxOutlineBlankIcon,
ErrorIcon,
IndeterminateCheckBoxIcon,
InfoIcon,
SuccessIcon,
WarningIcon,
} from './components/PixelIcon/index.js'
import blueStripeBg from './img/ui/blue-stripe-bg.png'
import lightBlueStripeBg from './img/ui/light-blue-stripe-bg.png'
import {
cardStyleSelectedSx,
cardStyleSx,
spriteShadowSx,
} from './styles/sx.js'
import { fontFaces } from './styles/fonts.js'
import {
pixelBevel,
pixelBevelPressed,
pixelFrameSx,
pixelPressedSx,
pixelShadowColor,
px,
} from './styles/pixel.js'
import { breakpoints, colors, fonts, layout } from './styles/tokens.js'
type PaletteColorName =
'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'
const paletteColorNames = new Set<string>([
'primary',
'secondary',
'error',
'warning',
'info',
'success',
])
const isPaletteColorName = (color: unknown): color is PaletteColorName =>
typeof color === 'string' && paletteColorNames.has(color)
// Pixel art outlines read best when they're a deep shade of the fill color
// rather than a flat black.
const outlineFor = (theme: Theme, color: unknown) =>
isPaletteColorName(color)
? darken(theme.palette[color].main, 0.5)
: colors.neutralOutline
// Every elevation gets the same hard, unblurred pixel art drop shadow rather
// than MUI's soft Material Design shadows.
const pixelShadow = `${px(1)} ${px(1)} 0 0 ${pixelShadowColor}`
const shadows = [
'none',
...Array<string>(24).fill(pixelShadow),
] as unknown as Shadows
// Shared by Button and Fab: a raised 9-slice frame that gets pushed into its
// shadow when pressed.
const raisedControlSx = (outline: string) =>
({
...pixelFrameSx({ outline, shadow: true }),
boxShadow: pixelBevel(),
'&:hover': { boxShadow: pixelBevel() },
'&.Mui-focusVisible': { boxShadow: pixelBevel() },
'&:active': pixelPressedSx(outline),
'&.Mui-disabled': {
...pixelFrameSx({ outline: colors.disabledOutline, shadow: 'pressed' }),
boxShadow: 'none',
},
}) as const
// These global styles used to live in Farmhand.sass, scoped by a `.Farmhand`
// class applied to many DOM roots across the app (including MUI Dialogs,
// which portal outside the main component tree). Injecting them once via
// MuiCssBaseline is a direct replacement that also sidesteps the reason
// that class trick existed in the first place (styling portalled content).
const globalStyleOverrides = {
body: {
overscrollBehavior: 'contain',
// The pixel fonts only ship the weights they're designed for. Faux bold
// and italics smear their pixel grid, so don't let the browser
// synthesize them.
fontSynthesis: 'none',
},
'ul, ol': { listStyle: 'none', margin: 0, padding: 0 },
p: { margin: 0 },
'.markdown': {
'& p': { margin: '1em 0' },
'& ul li': { listStyle: 'disc', marginLeft: '1em' },
'& strong': { fontWeight: 'bold' },
},
img: { imageRendering: 'pixelated' },
strong: { fontWeight: 'bold' },
'.visually_hidden:not(:focus):not(:active)': {
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: '1px',
overflow: 'hidden',
position: 'absolute',
whiteSpace: 'nowrap',
width: '1px',
},
'h4, h5, h6, p, p.MuiTypography-root': {
fontFamily: fonts.body,
},
'h3, h4, h5, h6': { fontWeight: 'bold' },
h2: { fontSize: '1.4em' },
'h3, h4': { margin: '1em 0' },
'p, .MuiTypography-body2': { lineHeight: '1.5em' },
'ul.card-list': {
flexGrow: 1,
marginBottom: '1em',
'& li': { margin: '1em 0' },
'& > li': { margin: '1em auto', maxWidth: layout.cardMaxWidth },
},
'input[type="number"]': { minWidth: '140px' },
th: { fontWeight: 'bold' },
'h1, h2, h3, h4, h5, h6, legend, td, th, .MuiTypography-h1, .MuiTypography-h2, .MuiTypography-h3, .MuiTypography-h4, .MuiTypography-h5, .MuiTypography-h6, .MuiButtonBase-root':
{
fontFamily: fonts.display,
},
'.danger-text': { color: colors.error },
'.success-text': { color: colors.success },
hr: { background: 'none' },
'.MuiDivider-vertical': { width: 'auto' },
'.Farmhand.notification-container': {
marginTop: '8em',
paddingTop: 0,
[`@media (min-width: ${breakpoints.md}px)`]: {
marginTop: '4em',
marginRight: '4em',
},
'& .MuiCollapse-wrapperInner': { width: '100%' },
'& .MuiCollapse-wrapper': { marginBottom: 0 },
},
// Workaround for Safari layout rendering issues wherein bottom padding
// for the stage and sidebar are not respected after interacting with the
// elements within them. Used by both Farmhand.tsx and Stage.tsx.
'.spacer': { minHeight: '7.5em' },
} as const
export default createTheme({
palette: {
mode: 'light',
},
// Pixel art has no anti-aliased curves. Corners are notched by the 9-slice
// frames instead.
shape: { borderRadius: 0 },
shadows,
typography: {
fontFamily: fonts.body,
// Buttons and Tabs derive their default styles from this rather than
// the `.MuiButtonBase-root` CssBaseline override below, which loses the
// cascade to their own emotion-generated styles.
button: {
fontFamily: fonts.display,
textTransform: 'none',
},
// DialogTitle (used by every modal header, e.g. Farmer's Log, Price
// Events) renders variant="h6". Same cascade issue as `button` above.
h6: {
fontFamily: fonts.display,
fontSize: '1.4em',
},
// CardHeader titles default to variant="h5" when no avatar is passed
// (e.g. the peer name headers in the Active Players modal). Same
// cascade issue as `button`/`h6` above.
h5: {
fontFamily: fonts.display,
},
},
components: {
MuiCssBaseline: {
// Each @font-face rule needs its own style object: emotion collapses
// an array of them under a single '@font-face' key into one rule.
styleOverrides: () => [
...fontFaces.map(fontFace => ({ '@font-face': fontFace })),
globalStyleOverrides,
],
},
MuiTabs: {
styleOverrides: {
root: {
...pixelFrameSx({ outline: colors.cardOutline }),
backgroundColor: '#ffeec6',
boxShadow: pixelBevel(),
},
indicator: {
height: px(1),
},
},
},
MuiInput: {
styleOverrides: {
root: {
margin: '1rem 0',
},
},
},
MuiCard: {
styleOverrides: {
root: ({ theme }) => ({
...cardStyleSx(theme),
'&.is-selected': cardStyleSelectedSx,
'& .MuiCard-root': {
backgroundColor: colors.cardBackgroundNested,
},
}),
},
},
MuiTableContainer: {
styleOverrides: {
root: ({ theme }) => ({
...cardStyleSx(theme),
'&.is-selected': cardStyleSelectedSx,
}),
},
},
MuiCardHeader: {
styleOverrides: {
avatar: {
'& img': spriteShadowSx,
},
},
},
MuiCardContent: {
styleOverrides: {
root: {
// Overrides an unhelpful MUI default
'&:last-child': { padding: '16px' },
},
},
},
MuiTableCell: {
styleOverrides: {
root: { borderColor: '#e9c777' },
},
},
MuiButtonBase: {
styleOverrides: {
root: {
textTransform: 'none',
// Ripples and focus pulses are circular by default, which clashes
// with the square pixel art controls.
'& .MuiTouchRipple-root .MuiTouchRipple-child': { borderRadius: 0 },
},
},
},
MuiButton: {
defaultProps: {
disableElevation: true,
},
styleOverrides: {
root: ({ ownerState, theme }) => {
const outline = outlineFor(theme, ownerState.color)
switch (ownerState.variant) {
case 'contained':
return raisedControlSx(outline)
case 'outlined':
return {
...pixelFrameSx({ outline, shadow: true }),
'&:hover': pixelFrameSx({ outline, shadow: true }),
'&:active': {
...pixelPressedSx(outline),
boxShadow: 'none',
},
'&.Mui-disabled': pixelFrameSx({
outline: colors.disabledOutline,
shadow: 'pressed',
}),
}
default:
// Text buttons get an invisible frame of the same size so
// they don't shift the layout when toggled to another variant
// (e.g. selected items in ItemList and Toolbelt).
return pixelFrameSx({ outline: 'transparent', shadow: 'pressed' })
}
},
},
},
MuiFab: {
styleOverrides: {
root: ({ ownerState, theme }) => ({
...raisedControlSx(outlineFor(theme, ownerState.color)),
// Fabs are normally round. Keep them square (with notched
// corners) to match the rest of the pixel art UI.
borderRadius: 0,
}),
},
},
MuiPaper: {
styleOverrides: {
rounded: { backgroundColor: '#fff7e7' },
},
},
MuiPopover: {
styleOverrides: {
paper: {
...pixelFrameSx({ outline: colors.cardOutline, shadow: true }),
boxShadow: pixelBevel(),
},
},
},
MuiAccordion: {
styleOverrides: {
root: ({ theme }) => ({
...cardStyleSx(theme),
'&:before': { display: 'none' },
'&.Mui-expanded': { margin: `${px(2)} 0` },
}),
},
},
MuiOutlinedInput: {
styleOverrides: {
notchedOutline: {
borderWidth: 2,
},
},
},
MuiSwitch: {
styleOverrides: {
thumb: {
borderRadius: 0,
boxShadow: `${pixelBevel()}, ${px(1)} ${px(1)} 0 0 ${pixelShadowColor}`,
},
track: {
borderRadius: 0,
},
},
},
MuiSlider: {
styleOverrides: {
thumb: {
borderRadius: 0,
boxShadow: pixelBevel(),
'&:hover, &.Mui-focusVisible, &.Mui-active': {
boxShadow: pixelBevelPressed,
},
'&:before': { boxShadow: 'none' },
},
rail: { borderRadius: 0 },
track: { borderRadius: 0 },
},
},
MuiSelect: {
defaultProps: {
IconComponent: ArrowDropDownIcon,
},
},
MuiCheckbox: {
defaultProps: {
icon: createElement(CheckBoxOutlineBlankIcon),
checkedIcon: createElement(CheckBoxIcon),
indeterminateIcon: createElement(IndeterminateCheckBoxIcon),
},
},
MuiTooltip: {
styleOverrides: {
tooltip: {
...pixelFrameSx({ outline: colors.neutralOutline }),
backgroundColor: 'rgba(60, 50, 40, 0.94)',
boxShadow: pixelBevel({
highlight: 'rgba(255, 255, 255, 0.15)',
lowlight: 'rgba(0, 0, 0, 0.25)',
}),
'& p': { textAlign: 'center' },
},
},
},
MuiDialog: {
styleOverrides: {
root: { display: 'block' },
paper: {
...pixelFrameSx({ outline: colors.dialogOutline, shadow: true }),
backgroundImage: `url(${lightBlueStripeBg})`,
boxShadow: pixelBevel(),
},
},
},
MuiDivider: {
styleOverrides: {
root: { margin: '1.5em 0' },
},
},
MuiAlert: {
defaultProps: {
iconMapping: {
success: createElement(SuccessIcon, { fontSize: 'inherit' }),
info: createElement(InfoIcon, { fontSize: 'inherit' }),
warning: createElement(WarningIcon, { fontSize: 'inherit' }),
error: createElement(ErrorIcon, { fontSize: 'inherit' }),
},
},
styleOverrides: {
// 24px keeps each of the 12x12 pixel icon's art pixels at a whole
// number of CSS pixels (MUI's default is 22px).
icon: { fontSize: 24 },
root: ({ ownerState, theme }) => {
const outline = outlineFor(theme, ownerState.severity ?? 'success')
return {
...pixelFrameSx({ outline, shadow: true }),
boxShadow: pixelBevel(),
marginBottom: '1em',
[`@media (min-width: 0px) and (orientation: landscape)`]: {
top: '3.5em',
},
[`@media (min-width: ${breakpoints.sm}px)`]: { top: '4.5em' },
'& p': {
margin: '1em 0',
'&:first-of-type': { marginTop: 0 },
'&:last-child': { marginBottom: 0 },
},
'& strong': { fontWeight: 'bold' },
'& li': { marginLeft: '1em' },
'& ul li': { listStyle: 'disc' },
'& ol li': { listStyle: 'decimal' },
}
},
},
},
},
})
// Re-exported so Farmhand.tsx can reference the same sidebar background
// asset without importing it a second time.
export { blueStripeBg }