@jeremyckahn/farmhand 1.23.3 → 1.23.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +4 -0
  2. package/dist/assets/index-4ynbl-GC.js +392 -0
  3. package/dist/assets/index-4ynbl-GC.js.map +1 -0
  4. package/dist/assets/index-CaYdR-lC.css +1 -0
  5. package/dist/assets/jersey-10-latin-400-normal-DrL73VYW.woff2 +0 -0
  6. package/dist/assets/jersey-10-latin-ext-400-normal-uIKqQSaG.woff2 +0 -0
  7. package/dist/assets/jersey-20-latin-400-normal-bNi78tgO.woff2 +0 -0
  8. package/dist/assets/jersey-20-latin-ext-400-normal-BO3lmWef.woff2 +0 -0
  9. package/dist/assets/jersey-25-latin-400-normal-BuYJnnt7.woff2 +0 -0
  10. package/dist/assets/jersey-25-latin-ext-400-normal-CP5Jl3WN.woff2 +0 -0
  11. package/dist/index.html +2 -2
  12. package/dist/service-worker.js +1 -1
  13. package/dist/service-worker.js.map +1 -1
  14. package/package.json +4 -8
  15. package/src/components/AccountingView/AccountingView.tsx +3 -1
  16. package/src/components/Achievement/Achievement.tsx +2 -2
  17. package/src/components/AppBar/AppBar.tsx +21 -7
  18. package/src/components/CowCard/Bloodline/Bloodline.tsx +0 -1
  19. package/src/components/CowCard/CowCard.tsx +7 -13
  20. package/src/components/CowCard/Subheader/Subheader.tsx +10 -18
  21. package/src/components/CowPen/Cow.tsx +5 -11
  22. package/src/components/CowPen/CowPen.test.tsx +4 -4
  23. package/src/components/CowPen/CowPen.tsx +1 -1
  24. package/src/components/CowPenContextMenu/CowPenContextMenu.tsx +2 -2
  25. package/src/components/Farmhand/Farmhand.tsx +7 -4
  26. package/src/components/Farmhand/useFarmhand.ts +12 -2
  27. package/src/components/Field/Field.tsx +2 -2
  28. package/src/components/Home/Home.tsx +2 -1
  29. package/src/components/Inventory/Inventory.tsx +2 -1
  30. package/src/components/Item/Item.tsx +11 -8
  31. package/src/components/LogView/LogView.tsx +6 -22
  32. package/src/components/Navigation/DayAndProgressContainer.tsx +0 -1
  33. package/src/components/Navigation/Navigation.tsx +12 -10
  34. package/src/components/NotificationSystem/NotificationSystem.test.tsx +41 -2
  35. package/src/components/NotificationSystem/NotificationSystem.tsx +40 -20
  36. package/src/components/PixelIcon/createPixelIcon.test.tsx +59 -0
  37. package/src/components/PixelIcon/createPixelIcon.tsx +97 -0
  38. package/src/components/PixelIcon/icons.ts +445 -0
  39. package/src/components/PixelIcon/index.ts +3 -0
  40. package/src/components/ProgressBar/ProgressBar.tsx +9 -3
  41. package/src/components/QuantityInput/QuantityInput.tsx +6 -2
  42. package/src/components/SearchBar/SearchBar.tsx +4 -4
  43. package/src/components/SettingsView/SettingsView.tsx +0 -2
  44. package/src/components/StatsView/StatsView.tsx +0 -3
  45. package/src/components/UpdateNotifier/UpdateNotifier.tsx +33 -5
  46. package/src/farmhand.d.ts +4 -0
  47. package/src/game-logic/reducers/showNotification.test.ts +20 -0
  48. package/src/game-logic/reducers/showNotification.ts +4 -2
  49. package/src/handlers/ui-events.tsx +8 -3
  50. package/src/index.tsx +0 -2
  51. package/src/mui-theme.ts +255 -35
  52. package/src/setupTests.ts +18 -12
  53. package/src/strings.ts +1 -1
  54. package/src/styles/fonts.ts +73 -0
  55. package/src/styles/pixel.ts +151 -0
  56. package/src/styles/sx.ts +17 -8
  57. package/src/styles/tokens.ts +11 -0
  58. package/src/utils/getCowImage.ts +0 -11
  59. package/dist/assets/francois-one-latin-400-normal-DD1rFI7H.woff2 +0 -0
  60. package/dist/assets/francois-one-latin-400-normal-DNXs7uOx.woff +0 -0
  61. package/dist/assets/francois-one-latin-ext-400-normal-CwRdWRZD.woff2 +0 -0
  62. package/dist/assets/francois-one-latin-ext-400-normal-JvkpT0Av.woff +0 -0
  63. package/dist/assets/francois-one-vietnamese-400-normal-BLIKeG5Y.woff2 +0 -0
  64. package/dist/assets/francois-one-vietnamese-400-normal-CuWeHlXs.woff +0 -0
  65. package/dist/assets/index-CNcrTYN-.css +0 -1
  66. package/dist/assets/index-Dihg7UqQ.js +0 -844
  67. package/dist/assets/index-Dihg7UqQ.js.map +0 -1
  68. package/dist/assets/public-sans-latin-400-normal-8Rpg0ruU.woff2 +0 -0
  69. package/dist/assets/public-sans-latin-400-normal-SBbinRkI.woff +0 -0
  70. package/dist/assets/public-sans-latin-ext-400-normal-B02wwOdZ.woff +0 -0
  71. package/dist/assets/public-sans-latin-ext-400-normal-mk90oQqJ.woff2 +0 -0
  72. package/dist/assets/public-sans-vietnamese-400-normal-BO1meqcx.woff +0 -0
  73. package/dist/assets/public-sans-vietnamese-400-normal-TxZB8OSn.woff2 +0 -0
  74. package/src/fontsource.d.ts +0 -3
@@ -541,8 +541,13 @@ export default {
541
541
  this: Farmhand,
542
542
  updateServiceWorker: (reloadPage?: boolean) => Promise<void>
543
543
  ) {
544
- this.showNotification(UPDATE_AVAILABLE, 'success', () => {
545
- updateServiceWorker(true)
546
- })
544
+ this.showNotification(
545
+ UPDATE_AVAILABLE,
546
+ 'success',
547
+ () => {
548
+ updateServiceWorker(true)
549
+ },
550
+ 'Reload'
551
+ )
547
552
  },
548
553
  }
package/src/index.tsx CHANGED
@@ -9,8 +9,6 @@ import { HashRouter as Router, Route } from 'react-router-dom'
9
9
 
10
10
  import Farmhand from './components/Farmhand/index.js'
11
11
  import { features } from './config.js'
12
- import '@fontsource/francois-one'
13
- import '@fontsource/public-sans'
14
12
 
15
13
  const FarmhandRoute = (props: any) => <Farmhand {...{ ...props, features }} />
16
14
 
package/src/mui-theme.ts CHANGED
@@ -1,5 +1,18 @@
1
+ import { darken, Theme } from '@mui/material/styles/index.js'
2
+ import { createElement } from 'react'
1
3
  import createTheme from '@mui/material/styles/createTheme.js'
4
+ import type { Shadows } from '@mui/material/styles/shadows.js'
2
5
 
6
+ import {
7
+ ArrowDropDownIcon,
8
+ CheckBoxIcon,
9
+ CheckBoxOutlineBlankIcon,
10
+ ErrorIcon,
11
+ IndeterminateCheckBoxIcon,
12
+ InfoIcon,
13
+ SuccessIcon,
14
+ WarningIcon,
15
+ } from './components/PixelIcon/index.js'
3
16
  import blueStripeBg from './img/ui/blue-stripe-bg.png'
4
17
  import lightBlueStripeBg from './img/ui/light-blue-stripe-bg.png'
5
18
  import {
@@ -7,7 +20,61 @@ import {
7
20
  cardStyleSx,
8
21
  spriteShadowSx,
9
22
  } from './styles/sx.js'
10
- import { breakpoints, colors, layout } from './styles/tokens.js'
23
+ import { fontFaces } from './styles/fonts.js'
24
+ import {
25
+ pixelBevel,
26
+ pixelBevelPressed,
27
+ pixelFrameSx,
28
+ pixelPressedSx,
29
+ pixelShadowColor,
30
+ px,
31
+ } from './styles/pixel.js'
32
+ import { breakpoints, colors, fonts, layout } from './styles/tokens.js'
33
+
34
+ type PaletteColorName =
35
+ 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'
36
+
37
+ const paletteColorNames = new Set<string>([
38
+ 'primary',
39
+ 'secondary',
40
+ 'error',
41
+ 'warning',
42
+ 'info',
43
+ 'success',
44
+ ])
45
+
46
+ const isPaletteColorName = (color: unknown): color is PaletteColorName =>
47
+ typeof color === 'string' && paletteColorNames.has(color)
48
+
49
+ // Pixel art outlines read best when they're a deep shade of the fill color
50
+ // rather than a flat black.
51
+ const outlineFor = (theme: Theme, color: unknown) =>
52
+ isPaletteColorName(color)
53
+ ? darken(theme.palette[color].main, 0.5)
54
+ : colors.neutralOutline
55
+
56
+ // Every elevation gets the same hard, unblurred pixel art drop shadow rather
57
+ // than MUI's soft Material Design shadows.
58
+ const pixelShadow = `${px(1)} ${px(1)} 0 0 ${pixelShadowColor}`
59
+ const shadows = [
60
+ 'none',
61
+ ...Array<string>(24).fill(pixelShadow),
62
+ ] as unknown as Shadows
63
+
64
+ // Shared by Button and Fab: a raised 9-slice frame that gets pushed into its
65
+ // shadow when pressed.
66
+ const raisedControlSx = (outline: string) =>
67
+ ({
68
+ ...pixelFrameSx({ outline, shadow: true }),
69
+ boxShadow: pixelBevel(),
70
+ '&:hover': { boxShadow: pixelBevel() },
71
+ '&.Mui-focusVisible': { boxShadow: pixelBevel() },
72
+ '&:active': pixelPressedSx(outline),
73
+ '&.Mui-disabled': {
74
+ ...pixelFrameSx({ outline: colors.disabledOutline, shadow: 'pressed' }),
75
+ boxShadow: 'none',
76
+ },
77
+ }) as const
11
78
 
12
79
  // These global styles used to live in Farmhand.sass, scoped by a `.Farmhand`
13
80
  // class applied to many DOM roots across the app (including MUI Dialogs,
@@ -15,7 +82,13 @@ import { breakpoints, colors, layout } from './styles/tokens.js'
15
82
  // MuiCssBaseline is a direct replacement that also sidesteps the reason
16
83
  // that class trick existed in the first place (styling portalled content).
17
84
  const globalStyleOverrides = {
18
- body: { overscrollBehavior: 'contain' },
85
+ body: {
86
+ overscrollBehavior: 'contain',
87
+ // The pixel fonts only ship the weights they're designed for. Faux bold
88
+ // and italics smear their pixel grid, so don't let the browser
89
+ // synthesize them.
90
+ fontSynthesis: 'none',
91
+ },
19
92
  'ul, ol': { listStyle: 'none', margin: 0, padding: 0 },
20
93
  p: { margin: 0 },
21
94
  '.markdown': {
@@ -35,7 +108,7 @@ const globalStyleOverrides = {
35
108
  width: '1px',
36
109
  },
37
110
  'h4, h5, h6, p, p.MuiTypography-root': {
38
- fontFamily: '"Public Sans", sans-serif',
111
+ fontFamily: fonts.body,
39
112
  },
40
113
  'h3, h4, h5, h6': { fontWeight: 'bold' },
41
114
  h2: { fontSize: '1.4em' },
@@ -51,7 +124,7 @@ const globalStyleOverrides = {
51
124
  th: { fontWeight: 'bold' },
52
125
  'h1, h2, h3, h4, h5, h6, legend, td, th, .MuiTypography-h1, .MuiTypography-h2, .MuiTypography-h3, .MuiTypography-h4, .MuiTypography-h5, .MuiTypography-h6, .MuiButtonBase-root':
53
126
  {
54
- fontFamily: '"Francois One", sans-serif',
127
+ fontFamily: fonts.display,
55
128
  },
56
129
  '.danger-text': { color: colors.error },
57
130
  '.success-text': { color: colors.success },
@@ -77,42 +150,51 @@ export default createTheme({
77
150
  palette: {
78
151
  mode: 'light',
79
152
  },
80
- shape: {},
153
+ // Pixel art has no anti-aliased curves. Corners are notched by the 9-slice
154
+ // frames instead.
155
+ shape: { borderRadius: 0 },
156
+ shadows,
81
157
  typography: {
82
- fontFamily: '"Public Sans", sans-serif',
158
+ fontFamily: fonts.body,
83
159
  // Buttons and Tabs derive their default styles from this rather than
84
160
  // the `.MuiButtonBase-root` CssBaseline override below, which loses the
85
161
  // cascade to their own emotion-generated styles.
86
162
  button: {
87
- fontFamily: '"Francois One", sans-serif',
163
+ fontFamily: fonts.display,
88
164
  textTransform: 'none',
89
165
  },
90
166
  // DialogTitle (used by every modal header, e.g. Farmer's Log, Price
91
167
  // Events) renders variant="h6". Same cascade issue as `button` above.
92
168
  h6: {
93
- fontFamily: '"Francois One", sans-serif',
169
+ fontFamily: fonts.display,
94
170
  fontSize: '1.4em',
95
171
  },
96
172
  // CardHeader titles default to variant="h5" when no avatar is passed
97
173
  // (e.g. the peer name headers in the Active Players modal). Same
98
174
  // cascade issue as `button`/`h6` above.
99
175
  h5: {
100
- fontFamily: '"Francois One", sans-serif',
176
+ fontFamily: fonts.display,
101
177
  },
102
178
  },
103
179
  components: {
104
180
  MuiCssBaseline: {
105
- styleOverrides: globalStyleOverrides,
181
+ // Each @font-face rule needs its own style object: emotion collapses
182
+ // an array of them under a single '@font-face' key into one rule.
183
+ styleOverrides: () => [
184
+ ...fontFaces.map(fontFace => ({ '@font-face': fontFace })),
185
+ globalStyleOverrides,
186
+ ],
106
187
  },
107
188
  MuiTabs: {
108
189
  styleOverrides: {
109
- root: ({ theme }) => ({
110
- background: '#ffeec6',
111
- borderColor: '#9b6d00',
112
- borderWidth: 2,
113
- borderStyle: 'solid',
114
- borderRadius: `${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0 0`,
115
- }),
190
+ root: {
191
+ ...pixelFrameSx({ outline: colors.cardOutline }),
192
+ backgroundColor: '#ffeec6',
193
+ boxShadow: pixelBevel(),
194
+ },
195
+ indicator: {
196
+ height: px(1),
197
+ },
116
198
  },
117
199
  },
118
200
  MuiInput: {
@@ -128,7 +210,7 @@ export default createTheme({
128
210
  ...cardStyleSx(theme),
129
211
  '&.is-selected': cardStyleSelectedSx,
130
212
  '& .MuiCard-root': {
131
- background: colors.cardBackgroundNested,
213
+ backgroundColor: colors.cardBackgroundNested,
132
214
  },
133
215
  }),
134
216
  },
@@ -163,7 +245,57 @@ export default createTheme({
163
245
  },
164
246
  MuiButtonBase: {
165
247
  styleOverrides: {
166
- root: { textTransform: 'none' },
248
+ root: {
249
+ textTransform: 'none',
250
+ // Ripples and focus pulses are circular by default, which clashes
251
+ // with the square pixel art controls.
252
+ '& .MuiTouchRipple-root .MuiTouchRipple-child': { borderRadius: 0 },
253
+ },
254
+ },
255
+ },
256
+ MuiButton: {
257
+ defaultProps: {
258
+ disableElevation: true,
259
+ },
260
+ styleOverrides: {
261
+ root: ({ ownerState, theme }) => {
262
+ const outline = outlineFor(theme, ownerState.color)
263
+
264
+ switch (ownerState.variant) {
265
+ case 'contained':
266
+ return raisedControlSx(outline)
267
+
268
+ case 'outlined':
269
+ return {
270
+ ...pixelFrameSx({ outline, shadow: true }),
271
+ '&:hover': pixelFrameSx({ outline, shadow: true }),
272
+ '&:active': {
273
+ ...pixelPressedSx(outline),
274
+ boxShadow: 'none',
275
+ },
276
+ '&.Mui-disabled': pixelFrameSx({
277
+ outline: colors.disabledOutline,
278
+ shadow: 'pressed',
279
+ }),
280
+ }
281
+
282
+ default:
283
+ // Text buttons get an invisible frame of the same size so
284
+ // they don't shift the layout when toggled to another variant
285
+ // (e.g. selected items in ItemList and Toolbelt).
286
+ return pixelFrameSx({ outline: 'transparent', shadow: 'pressed' })
287
+ }
288
+ },
289
+ },
290
+ },
291
+ MuiFab: {
292
+ styleOverrides: {
293
+ root: ({ ownerState, theme }) => ({
294
+ ...raisedControlSx(outlineFor(theme, ownerState.color)),
295
+ // Fabs are normally round. Keep them square (with notched
296
+ // corners) to match the rest of the pixel art UI.
297
+ borderRadius: 0,
298
+ }),
167
299
  },
168
300
  },
169
301
  MuiPaper: {
@@ -171,9 +303,76 @@ export default createTheme({
171
303
  rounded: { backgroundColor: '#fff7e7' },
172
304
  },
173
305
  },
306
+ MuiPopover: {
307
+ styleOverrides: {
308
+ paper: {
309
+ ...pixelFrameSx({ outline: colors.cardOutline, shadow: true }),
310
+ boxShadow: pixelBevel(),
311
+ },
312
+ },
313
+ },
314
+ MuiAccordion: {
315
+ styleOverrides: {
316
+ root: ({ theme }) => ({
317
+ ...cardStyleSx(theme),
318
+ '&:before': { display: 'none' },
319
+ '&.Mui-expanded': { margin: `${px(2)} 0` },
320
+ }),
321
+ },
322
+ },
323
+ MuiOutlinedInput: {
324
+ styleOverrides: {
325
+ notchedOutline: {
326
+ borderWidth: 2,
327
+ },
328
+ },
329
+ },
330
+ MuiSwitch: {
331
+ styleOverrides: {
332
+ thumb: {
333
+ borderRadius: 0,
334
+ boxShadow: `${pixelBevel()}, ${px(1)} ${px(1)} 0 0 ${pixelShadowColor}`,
335
+ },
336
+ track: {
337
+ borderRadius: 0,
338
+ },
339
+ },
340
+ },
341
+ MuiSlider: {
342
+ styleOverrides: {
343
+ thumb: {
344
+ borderRadius: 0,
345
+ boxShadow: pixelBevel(),
346
+ '&:hover, &.Mui-focusVisible, &.Mui-active': {
347
+ boxShadow: pixelBevelPressed,
348
+ },
349
+ '&:before': { boxShadow: 'none' },
350
+ },
351
+ rail: { borderRadius: 0 },
352
+ track: { borderRadius: 0 },
353
+ },
354
+ },
355
+ MuiSelect: {
356
+ defaultProps: {
357
+ IconComponent: ArrowDropDownIcon,
358
+ },
359
+ },
360
+ MuiCheckbox: {
361
+ defaultProps: {
362
+ icon: createElement(CheckBoxOutlineBlankIcon),
363
+ checkedIcon: createElement(CheckBoxIcon),
364
+ indeterminateIcon: createElement(IndeterminateCheckBoxIcon),
365
+ },
366
+ },
174
367
  MuiTooltip: {
175
368
  styleOverrides: {
176
369
  tooltip: {
370
+ ...pixelFrameSx({ outline: colors.neutralOutline }),
371
+ backgroundColor: 'rgba(60, 50, 40, 0.94)',
372
+ boxShadow: pixelBevel({
373
+ highlight: 'rgba(255, 255, 255, 0.15)',
374
+ lowlight: 'rgba(0, 0, 0, 0.25)',
375
+ }),
177
376
  '& p': { textAlign: 'center' },
178
377
  },
179
378
  },
@@ -181,7 +380,11 @@ export default createTheme({
181
380
  MuiDialog: {
182
381
  styleOverrides: {
183
382
  root: { display: 'block' },
184
- paper: { backgroundImage: `url(${lightBlueStripeBg})` },
383
+ paper: {
384
+ ...pixelFrameSx({ outline: colors.dialogOutline, shadow: true }),
385
+ backgroundImage: `url(${lightBlueStripeBg})`,
386
+ boxShadow: pixelBevel(),
387
+ },
185
388
  },
186
389
  },
187
390
  MuiDivider: {
@@ -190,22 +393,39 @@ export default createTheme({
190
393
  },
191
394
  },
192
395
  MuiAlert: {
396
+ defaultProps: {
397
+ iconMapping: {
398
+ success: createElement(SuccessIcon, { fontSize: 'inherit' }),
399
+ info: createElement(InfoIcon, { fontSize: 'inherit' }),
400
+ warning: createElement(WarningIcon, { fontSize: 'inherit' }),
401
+ error: createElement(ErrorIcon, { fontSize: 'inherit' }),
402
+ },
403
+ },
193
404
  styleOverrides: {
194
- root: {
195
- marginBottom: '1em',
196
- [`@media (min-width: 0px) and (orientation: landscape)`]: {
197
- top: '3.5em',
198
- },
199
- [`@media (min-width: ${breakpoints.sm}px)`]: { top: '4.5em' },
200
- '& p': {
201
- margin: '1em 0',
202
- '&:first-of-type': { marginTop: 0 },
203
- '&:last-child': { marginBottom: 0 },
204
- },
205
- '& strong': { fontWeight: 'bold' },
206
- '& li': { marginLeft: '1em' },
207
- '& ul li': { listStyle: 'disc' },
208
- '& ol li': { listStyle: 'decimal' },
405
+ // 24px keeps each of the 12x12 pixel icon's art pixels at a whole
406
+ // number of CSS pixels (MUI's default is 22px).
407
+ icon: { fontSize: 24 },
408
+ root: ({ ownerState, theme }) => {
409
+ const outline = outlineFor(theme, ownerState.severity ?? 'success')
410
+
411
+ return {
412
+ ...pixelFrameSx({ outline, shadow: true }),
413
+ boxShadow: pixelBevel(),
414
+ marginBottom: '1em',
415
+ [`@media (min-width: 0px) and (orientation: landscape)`]: {
416
+ top: '3.5em',
417
+ },
418
+ [`@media (min-width: ${breakpoints.sm}px)`]: { top: '4.5em' },
419
+ '& p': {
420
+ margin: '1em 0',
421
+ '&:first-of-type': { marginTop: 0 },
422
+ '&:last-child': { marginBottom: 0 },
423
+ },
424
+ '& strong': { fontWeight: 'bold' },
425
+ '& li': { marginLeft: '1em' },
426
+ '& ul li': { listStyle: 'disc' },
427
+ '& ol li': { listStyle: 'decimal' },
428
+ }
209
429
  },
210
430
  },
211
431
  },
package/src/setupTests.ts CHANGED
@@ -1,6 +1,22 @@
1
- import '@testing-library/jest-dom'
2
1
  import 'jest-extended'
3
- import { cleanup } from '@testing-library/react'
2
+
3
+ // Conditionally import DOM matchers and React cleanup only when running in a
4
+ // browser/JSDOM environment. Keeping them out of Node-based test runners avoids
5
+ // loading heavy React and DOM libraries into pure logic test workers.
6
+ if (typeof window !== 'undefined') {
7
+ await import('@testing-library/jest-dom')
8
+ const { cleanup } = await import('@testing-library/react')
9
+
10
+ afterEach(() => {
11
+ cleanup()
12
+ // Farmhand mirrors navigation state (current view, tabbed screens' active
13
+ // tab) into the real window.location.hash (see src/utils/hashQueryParams.ts),
14
+ // which - unlike React/component state - isn't reset between tests on its
15
+ // own and would otherwise leak into whichever test runs next in the same
16
+ // file.
17
+ window.history.replaceState({}, '', window.location.pathname)
18
+ })
19
+ }
4
20
 
5
21
  const localForageStore = new Map<string, any>()
6
22
 
@@ -49,13 +65,3 @@ beforeEach(() => {
49
65
  // on a specific month is not run in tests (unless getMonth is re-mocked).
50
66
  vitest.spyOn(Date.prototype, 'getMonth').mockReturnValue(-1)
51
67
  })
52
-
53
- afterEach(() => {
54
- cleanup()
55
- // Farmhand mirrors navigation state (current view, tabbed screens' active
56
- // tab) into the real window.location.hash (see src/utils/hashQueryParams.ts),
57
- // which - unlike React/component state - isn't reset between tests on its
58
- // own and would otherwise leak into whichever test runs next in the same
59
- // file.
60
- window.history.replaceState({}, '', window.location.pathname)
61
- })
package/src/strings.ts CHANGED
@@ -18,7 +18,7 @@ export const COW_SOLD_TOOLTIP_TEXT =
18
18
  export const DATA_DELETED = 'Your local game data has been deleted.'
19
19
  export const INVALID_DATA_PROVIDED = 'Invalid Farmhand data provided.'
20
20
  export const UPDATE_AVAILABLE =
21
- "A game update is available! Click this message to reload and see what's new."
21
+ "A game update is available! Reload to see what's new."
22
22
  export const SERVER_ERROR =
23
23
  'There was an issue connecting to the server. Please try again in a moment.'
24
24
  export const CONNECTING_TO_SERVER = 'Connecting...'
@@ -0,0 +1,73 @@
1
+ // Pixel font faces: Jersey 10 for headings and UI labels, Jersey 20 for body
2
+ // text, and Jersey 25 (a heavier cut of the same design) as the body bold.
3
+ //
4
+ // The app refers to two families, "Farmhand Display" (headings, buttons and
5
+ // other UI labels) and "Farmhand Body" (running text), so the typefaces
6
+ // behind them can be swapped here without touching any components.
7
+ //
8
+ // These are declared here rather than by importing the @fontsource CSS so
9
+ // that each face can set `size-adjust`. Pixel fonts have much smaller glyphs
10
+ // per em than the fonts they replaced (Francois One and Public Sans), so
11
+ // without it every heading and paragraph in the app would need its
12
+ // font-size tweaked. The adjustments below match each face's cap height to
13
+ // that of the font it replaced.
14
+
15
+ import jersey10Latin from '@fontsource/jersey-10/files/jersey-10-latin-400-normal.woff2'
16
+ import jersey10LatinExt from '@fontsource/jersey-10/files/jersey-10-latin-ext-400-normal.woff2'
17
+ import jersey20Latin from '@fontsource/jersey-20/files/jersey-20-latin-400-normal.woff2'
18
+ import jersey20LatinExt from '@fontsource/jersey-20/files/jersey-20-latin-ext-400-normal.woff2'
19
+ import jersey25Latin from '@fontsource/jersey-25/files/jersey-25-latin-400-normal.woff2'
20
+ import jersey25LatinExt from '@fontsource/jersey-25/files/jersey-25-latin-ext-400-normal.woff2'
21
+
22
+ // Copied from the @fontsource CSS.
23
+ const unicodeRanges = {
24
+ latin:
25
+ 'U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD',
26
+ latinExt:
27
+ 'U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF',
28
+ }
29
+
30
+ const fontFace = (
31
+ family: string,
32
+ weight: number,
33
+ sizeAdjust: string,
34
+ url: string,
35
+ unicodeRange: string
36
+ ) => ({
37
+ // Quoted because unquoted family names can't contain a word that starts
38
+ // with a digit.
39
+ fontFamily: `"${family}"`,
40
+ fontStyle: 'normal',
41
+ fontDisplay: 'swap',
42
+ fontWeight: weight,
43
+ sizeAdjust,
44
+ src: `url("${url}") format('woff2')`,
45
+ unicodeRange,
46
+ })
47
+
48
+ export const fontFaces = [
49
+ fontFace('Farmhand Display', 400, '140%', jersey10Latin, unicodeRanges.latin),
50
+ fontFace(
51
+ 'Farmhand Display',
52
+ 400,
53
+ '140%',
54
+ jersey10LatinExt,
55
+ unicodeRanges.latinExt
56
+ ),
57
+ fontFace('Farmhand Body', 400, '124%', jersey20Latin, unicodeRanges.latin),
58
+ fontFace(
59
+ 'Farmhand Body',
60
+ 400,
61
+ '124%',
62
+ jersey20LatinExt,
63
+ unicodeRanges.latinExt
64
+ ),
65
+ fontFace('Farmhand Body', 700, '120%', jersey25Latin, unicodeRanges.latin),
66
+ fontFace(
67
+ 'Farmhand Body',
68
+ 700,
69
+ '120%',
70
+ jersey25LatinExt,
71
+ unicodeRanges.latinExt
72
+ ),
73
+ ]
@@ -0,0 +1,151 @@
1
+ // Retro pixel art UI chrome built on CSS 9-slice scaling (`border-image`).
2
+ //
3
+ // Each frame is a tiny SVG "sprite" (a few pixels square) that the browser
4
+ // slices into corners, edges and a center. Corners are drawn at a fixed
5
+ // size and edges stretch, so a frame scales to any element size while every
6
+ // art pixel stays exactly PIXEL_SIZE CSS pixels. Because the sprites are
7
+ // generated as SVGs, their colors can be derived from the MUI palette at
8
+ // runtime instead of needing a hand-drawn PNG per color.
9
+ //
10
+ // Only the outline, the notched corners and the hard drop shadow live in
11
+ // the 9-slice image. The element's background is clipped to its padding box
12
+ // so it fills the frame's interior, and the bevel highlight is drawn with
13
+ // inset box-shadows. That keeps `backgroundColor` overrides (hover states,
14
+ // selected cards, palette colors, etc.) working without regenerating any
15
+ // images.
16
+
17
+ /**
18
+ * The size, in CSS pixels, of one "art pixel" in the UI chrome.
19
+ */
20
+ export const PIXEL_SIZE = 3
21
+
22
+ /**
23
+ * Converts a number of art pixels into a CSS length.
24
+ */
25
+ export const px = (units: number) => `${units * PIXEL_SIZE}px`
26
+
27
+ /**
28
+ * Converts a pixel map into a CSS `url()` for an SVG data URI. Each string in
29
+ * `rows` is one row of pixels, and each character is a key of `palette`. A
30
+ * `.` is a transparent pixel.
31
+ */
32
+ export const pixelArtUrl = (
33
+ rows: string[],
34
+ palette: Record<string, string>
35
+ ): string => {
36
+ const height = rows.length
37
+ const width = rows[0].length
38
+ let rects = ''
39
+
40
+ rows.forEach((row, y) => {
41
+ ;[...row].forEach((char, x) => {
42
+ const fill = palette[char]
43
+
44
+ if (char === '.' || !fill) return
45
+
46
+ rects += `<rect x="${x}" y="${y}" width="1" height="1" fill="${fill}"/>`
47
+ })
48
+ })
49
+
50
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges">${rects}</svg>`
51
+
52
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
53
+ }
54
+
55
+ // O = outline, D = drop shadow
56
+ //
57
+ // A 1px notch in each corner gives the classic pixel art "rounded" box. The
58
+ // center pixel is left transparent; the element's own background shows
59
+ // through it.
60
+ const FRAME = ['.O.', 'O.O', '.O.']
61
+ const FRAME_SLICE = '1'
62
+ const FRAME_WIDTH = px(1)
63
+
64
+ // Same as FRAME, plus a hard drop shadow offset by one art pixel to the
65
+ // bottom right. The right and bottom slices are two pixels wide to hold the
66
+ // shadow.
67
+ const SHADOWED_FRAME = ['.O..', 'O.OD', '.ODD', '.DD.']
68
+ const SHADOWED_FRAME_SLICE = '1 2 2 1'
69
+ const SHADOWED_FRAME_WIDTH = `${px(1)} ${px(2)} ${px(2)} ${px(1)}`
70
+
71
+ export const pixelShadowColor = 'rgba(0, 0, 0, 0.3)'
72
+
73
+ export interface PixelFrameOptions {
74
+ /**
75
+ * The outline color.
76
+ */
77
+ outline: string
78
+ /**
79
+ * Whether to render a hard drop shadow. When this is `'pressed'`, space
80
+ * for the shadow is preserved (so the element's size doesn't change) but
81
+ * the shadow itself is not drawn. Pair that with `pixelPressedSx`.
82
+ */
83
+ shadow?: boolean | 'pressed'
84
+ }
85
+
86
+ /**
87
+ * Returns sx styles that render a scalable 9-slice pixel art frame around an
88
+ * element.
89
+ */
90
+ export const pixelFrameSx = ({
91
+ outline,
92
+ shadow = false,
93
+ }: PixelFrameOptions) => {
94
+ const rows = shadow ? SHADOWED_FRAME : FRAME
95
+ const slice = shadow ? SHADOWED_FRAME_SLICE : FRAME_SLICE
96
+ const width = shadow ? SHADOWED_FRAME_WIDTH : FRAME_WIDTH
97
+ const image = pixelArtUrl(rows, {
98
+ O: outline,
99
+ D: shadow === true ? pixelShadowColor : 'transparent',
100
+ })
101
+
102
+ return {
103
+ borderStyle: 'solid',
104
+ borderColor: 'transparent',
105
+ borderWidth: width,
106
+ borderRadius: 0,
107
+ borderImageSource: image,
108
+ borderImageSlice: slice,
109
+ borderImageWidth: width,
110
+ borderImageRepeat: 'stretch',
111
+ // Keep the background inside the outline so the notched corners and the
112
+ // drop shadow stay transparent.
113
+ backgroundClip: 'padding-box',
114
+ } as const
115
+ }
116
+
117
+ /**
118
+ * Returns a `box-shadow` value that draws a one-art-pixel bevel inside an
119
+ * element: a light edge on the top and left, and a dark edge on the bottom
120
+ * and right.
121
+ */
122
+ export const pixelBevel = ({
123
+ highlight = 'rgba(255, 255, 255, 0.45)',
124
+ lowlight = 'rgba(0, 0, 0, 0.12)',
125
+ }: { highlight?: string; lowlight?: string } = {}) =>
126
+ `inset ${px(1)} ${px(1)} 0 ${highlight}, inset -${px(1)} -${px(1)} 0 ${lowlight}`
127
+
128
+ /**
129
+ * Inverted bevel for pressed/active controls.
130
+ */
131
+ export const pixelBevelPressed = pixelBevel({
132
+ highlight: 'rgba(0, 0, 0, 0.15)',
133
+ lowlight: 'rgba(255, 255, 255, 0.25)',
134
+ })
135
+
136
+ /**
137
+ * A crisp (unblurred) drop shadow for sprites and other arbitrarily shaped
138
+ * elements.
139
+ */
140
+ export const pixelDropShadowFilter = `drop-shadow(${px(1)} ${px(1)} 0 rgba(0, 0, 0, 0.2))`
141
+
142
+ /**
143
+ * Styles for the pressed state of a control framed with `shadow: true`. The
144
+ * control shifts into the space its shadow occupied, so it appears to be
145
+ * pushed down.
146
+ */
147
+ export const pixelPressedSx = (outline: string) => ({
148
+ ...pixelFrameSx({ outline, shadow: 'pressed' }),
149
+ transform: `translate(${px(1)}, ${px(1)})`,
150
+ boxShadow: pixelBevelPressed,
151
+ })