@micha.bigler/ui-core-micha 2.29.2 → 2.30.0

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.
@@ -0,0 +1,376 @@
1
+ import { darken, lighten } from '@mui/material/styles';
2
+
3
+ // MUI's palette augmentation (createPalette -> augmentColor) only runs on the
4
+ // FIRST argument passed to createTheme; every later argument -- which is how
5
+ // createAppTheme layers BASELINE_PALETTE on top -- is deep-merged as a raw
6
+ // object with no augmentation. A status entry missing `main`/`contrastText`
7
+ // therefore silently keeps MUI's stock hue for `color="success"` etc. even
8
+ // though `text`/`fill`/`bg` are set correctly, so every status channel MUI
9
+ // recognises (success/warning/error/info) must carry its own explicit
10
+ // main/light/dark/contrastText, computed the same way augmentColor would.
11
+ const withMainShades = (main, contrastText) => ({
12
+ main,
13
+ light: lighten(main, 0.2),
14
+ dark: darken(main, 0.3),
15
+ contrastText,
16
+ });
17
+
18
+ export const SERIES_COLOURS = [
19
+ '#3D5A99',
20
+ '#3E80B8',
21
+ '#2E8F8A',
22
+ '#7A5FA8',
23
+ '#9C4F86',
24
+ '#8A7355',
25
+ ];
26
+
27
+ export const STATUS_KEYS = ['success', 'warning', 'error', 'info', 'stale'];
28
+
29
+ export const TYPOGRAPHY_VARIANTS = [
30
+ 'h1',
31
+ 'h2',
32
+ 'h3',
33
+ 'h4',
34
+ 'h5',
35
+ 'h6',
36
+ 'subtitle1',
37
+ 'subtitle2',
38
+ 'body1',
39
+ 'body2',
40
+ 'button',
41
+ 'caption',
42
+ 'overline',
43
+ ];
44
+
45
+ const typeVariant = (fontSize, fontWeight, lineHeight, extra = {}) => ({
46
+ fontSize,
47
+ fontWeight,
48
+ lineHeight,
49
+ letterSpacing: 0,
50
+ ...extra,
51
+ });
52
+
53
+ export const BASELINE_PALETTE = {
54
+ ink: {
55
+ primary: '#212529',
56
+ secondary: '#5B6670',
57
+ muted: '#6A7178',
58
+ },
59
+ text: {
60
+ primary: '#212529',
61
+ secondary: '#5B6670',
62
+ disabled: '#6A7178',
63
+ },
64
+ background: {
65
+ default: '#FAFAFA',
66
+ paper: '#FFFFFF',
67
+ },
68
+ divider: 'rgba(33,37,41,.10)',
69
+ controlBorder: {
70
+ main: 'rgba(33,37,41,.50)',
71
+ hover: 'rgba(33,37,41,.65)',
72
+ error: '#BF3227',
73
+ },
74
+ success: {
75
+ ...withMainShades('#1B8038', '#FFFFFF'),
76
+ text: '#35794A',
77
+ fill: '#1B8038',
78
+ fillText: '#FFFFFF',
79
+ bg: '#E5F4E9',
80
+ },
81
+ warning: {
82
+ ...withMainShades('#C08A2C', '#212529'),
83
+ text: '#976100',
84
+ fill: '#C08A2C',
85
+ fillText: '#212529',
86
+ bg: '#FBF0DC',
87
+ },
88
+ error: {
89
+ ...withMainShades('#BF3227', '#FFFFFF'),
90
+ text: '#BF3227',
91
+ fill: '#BF3227',
92
+ fillText: '#FFFFFF',
93
+ bg: '#FBEAE8',
94
+ },
95
+ // The canonical sheet introduces no new info hue. These values reuse
96
+ // MUI's own info.dark/main family while adding the shared status shape.
97
+ info: {
98
+ ...withMainShades('#01579B', '#FFFFFF'),
99
+ text: '#01579B',
100
+ fill: '#01579B',
101
+ fillText: '#FFFFFF',
102
+ bg: '#E5F6FD',
103
+ },
104
+ stale: {
105
+ text: '#5B6B7D',
106
+ fill: '#5B6B7D',
107
+ fillText: '#FFFFFF',
108
+ bg: '#EAEDF1',
109
+ },
110
+ dataSeries: {
111
+ categorical: SERIES_COLOURS,
112
+ },
113
+ };
114
+
115
+ export const OVERLAY_SHADOW =
116
+ '0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)';
117
+
118
+ export const MOTION = {
119
+ duration: {
120
+ fast: 120,
121
+ base: 180,
122
+ overlayEnter: 220,
123
+ overlayExit: 180,
124
+ chart: 300,
125
+ },
126
+ easing: {
127
+ enter: 'cubic-bezier(0.0, 0, 0.2, 1)',
128
+ exit: 'cubic-bezier(0.4, 0, 1, 1)',
129
+ state: 'cubic-bezier(0.4, 0, 0.2, 1)',
130
+ },
131
+ reduced: '0.01ms',
132
+ };
133
+
134
+ const coarseHitArea = {
135
+ '@media (any-pointer: coarse)': {
136
+ minHeight: 44,
137
+ minWidth: 44,
138
+ },
139
+ };
140
+
141
+ const autofill = {
142
+ '&:-webkit-autofill': {
143
+ WebkitBoxShadow: '0 0 0 100px #FFFFFF inset',
144
+ WebkitTextFillColor: '#212529',
145
+ caretColor: '#212529',
146
+ },
147
+ '&:autofill': {
148
+ boxShadow: '0 0 0 100px #FFFFFF inset',
149
+ WebkitTextFillColor: '#212529',
150
+ caretColor: '#212529',
151
+ },
152
+ };
153
+
154
+ export const BASELINE_STATIC = {
155
+ typography: {
156
+ fontFamily: "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
157
+ h1: typeVariant('32px', 600, 1.2),
158
+ h2: typeVariant('28px', 600, 1.22),
159
+ h3: typeVariant('24px', 600, 1.25),
160
+ h4: typeVariant('20px', 600, 1.3),
161
+ h5: typeVariant('18px', 600, 1.3),
162
+ h6: typeVariant('16px', 600, 1.35),
163
+ subtitle1: typeVariant('15px', 600, 1.4),
164
+ subtitle2: typeVariant('13px', 500, 1.4),
165
+ body1: typeVariant('14px', 400, 1.55),
166
+ body2: typeVariant('13px', 400, 1.55),
167
+ button: typeVariant('14px', 500, 1.4, { textTransform: 'none' }),
168
+ caption: typeVariant('12px', 400, 1.4),
169
+ overline: typeVariant('11px', 600, 1.4, {
170
+ textTransform: 'uppercase',
171
+ letterSpacing: '0.4px',
172
+ }),
173
+ },
174
+ shape: {
175
+ borderRadius: 3,
176
+ },
177
+ radius: {
178
+ control: 3,
179
+ card: 8,
180
+ },
181
+ spacing: 8,
182
+ breakpoints: {
183
+ values: { xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536 },
184
+ },
185
+ density: {
186
+ controlHeight: 40,
187
+ touchTarget: 44,
188
+ tableCellPadding: '10px 16px',
189
+ chip: { height: 32, radius: 16, fontSize: 13 },
190
+ containerGutter: { default: 24, wide: 32 },
191
+ },
192
+ fontLoading: {
193
+ family: 'DM Sans',
194
+ weights: [400, 500, 600],
195
+ source: '@fontsource/dm-sans',
196
+ },
197
+ shadow: {
198
+ rest: 'none',
199
+ overlay: OVERLAY_SHADOW,
200
+ },
201
+ motion: MOTION,
202
+ transitions: {
203
+ duration: {
204
+ fast: MOTION.duration.fast,
205
+ base: MOTION.duration.base,
206
+ overlayEnter: MOTION.duration.overlayEnter,
207
+ overlayExit: MOTION.duration.overlayExit,
208
+ chart: MOTION.duration.chart,
209
+ },
210
+ easing: {
211
+ enter: MOTION.easing.enter,
212
+ exit: MOTION.easing.exit,
213
+ state: MOTION.easing.state,
214
+ },
215
+ },
216
+ // MUI's 25-entry shadows array is intentionally retained intact. Resting
217
+ // surfaces are made shadowless via the Paper/Card defaults below, while
218
+ // named transient surfaces receive shadow.overlay explicitly.
219
+ components: {
220
+ MuiCssBaseline: {
221
+ styleOverrides: {
222
+ '@media (prefers-reduced-motion: reduce)': {
223
+ '*, *::before, *::after': {
224
+ animationDuration: '0.01ms !important',
225
+ animationIterationCount: '1 !important',
226
+ transitionDuration: '0.01ms !important',
227
+ scrollBehavior: 'auto !important',
228
+ },
229
+ },
230
+ },
231
+ },
232
+ MuiButton: {
233
+ defaultProps: { disableElevation: true },
234
+ styleOverrides: {
235
+ root: {
236
+ minHeight: 40,
237
+ borderRadius: 3,
238
+ textTransform: 'none',
239
+ transitionDuration: '120ms',
240
+ ...coarseHitArea,
241
+ },
242
+ contained: { boxShadow: 'none', '&:hover': { boxShadow: 'none' } },
243
+ },
244
+ },
245
+ MuiTableCell: {
246
+ styleOverrides: { root: { padding: '10px 16px' } },
247
+ },
248
+ MuiIconButton: {
249
+ styleOverrides: { root: { minHeight: 40, minWidth: 40, ...coarseHitArea } },
250
+ },
251
+ MuiDivider: {
252
+ styleOverrides: { root: { borderColor: 'rgba(33,37,41,.10)' } },
253
+ },
254
+ MuiSelect: {
255
+ defaultProps: { variant: 'outlined' },
256
+ styleOverrides: {
257
+ select: {
258
+ minHeight: 40,
259
+ boxSizing: 'border-box',
260
+ '@media (any-pointer: coarse)': { minHeight: 44 },
261
+ },
262
+ },
263
+ },
264
+ MuiCheckbox: {
265
+ styleOverrides: { root: { minHeight: 40, minWidth: 40, ...coarseHitArea } },
266
+ },
267
+ MuiFormControlLabel: {
268
+ styleOverrides: {
269
+ root: { minHeight: 40, ...coarseHitArea },
270
+ label: { fontSize: '14px', lineHeight: 1.55 },
271
+ },
272
+ },
273
+ MuiContainer: {
274
+ styleOverrides: {
275
+ root: {
276
+ paddingLeft: 24,
277
+ paddingRight: 24,
278
+ '@media (min-width:1200px)': { paddingLeft: 32, paddingRight: 32 },
279
+ },
280
+ },
281
+ },
282
+ MuiPaper: {
283
+ defaultProps: { elevation: 0, variant: 'outlined' },
284
+ styleOverrides: {
285
+ root: { borderRadius: 8, boxShadow: 'none' },
286
+ },
287
+ },
288
+ MuiCard: {
289
+ defaultProps: { elevation: 0, variant: 'outlined' },
290
+ styleOverrides: { root: { borderRadius: 8, boxShadow: 'none' } },
291
+ },
292
+ MuiChip: {
293
+ styleOverrides: {
294
+ root: {
295
+ height: 32,
296
+ borderRadius: 16,
297
+ fontSize: '13px',
298
+ position: 'relative',
299
+ '@media (any-pointer: coarse)': {
300
+ '&::after': {
301
+ content: '""',
302
+ position: 'absolute',
303
+ inset: '-6px',
304
+ },
305
+ },
306
+ },
307
+ },
308
+ },
309
+ MuiTextField: {
310
+ defaultProps: { variant: 'outlined' },
311
+ styleOverrides: { root: { minHeight: 40, ...coarseHitArea } },
312
+ },
313
+ MuiOutlinedInput: {
314
+ styleOverrides: {
315
+ root: {
316
+ minHeight: 40,
317
+ borderRadius: 3,
318
+ ...coarseHitArea,
319
+ },
320
+ input: autofill,
321
+ },
322
+ // `variants` is a sibling of `styleOverrides`, not nested inside it -
323
+ // MUI matches these by `props` and applies `style` independently of the
324
+ // styleOverrides merge, so this is the correct placement (a `variants`
325
+ // array embedded inside styleOverrides.root would just be inert CSS-in-JS).
326
+ variants: [
327
+ { props: { multiline: false }, style: { height: 40 } },
328
+ ],
329
+ },
330
+ MuiFilledInput: {
331
+ styleOverrides: { input: autofill },
332
+ },
333
+ MuiInput: {
334
+ styleOverrides: { input: autofill },
335
+ },
336
+ MuiDialog: {
337
+ defaultProps: { transitionDuration: { enter: 220, exit: 180 } },
338
+ styleOverrides: { paper: { borderRadius: 8, boxShadow: OVERLAY_SHADOW } },
339
+ },
340
+ MuiDrawer: {
341
+ defaultProps: { transitionDuration: { enter: 220, exit: 180 } },
342
+ styleOverrides: { paper: { boxShadow: OVERLAY_SHADOW } },
343
+ },
344
+ MuiMenu: {
345
+ defaultProps: { transitionDuration: { enter: 220, exit: 180 } },
346
+ styleOverrides: { paper: { boxShadow: OVERLAY_SHADOW } },
347
+ },
348
+ MuiTooltip: {
349
+ defaultProps: {
350
+ slotProps: { transition: { timeout: { enter: 220, exit: 180 } } },
351
+ },
352
+ styleOverrides: {
353
+ tooltip: { boxShadow: OVERLAY_SHADOW, transitionDuration: '220ms' },
354
+ },
355
+ },
356
+ },
357
+ };
358
+
359
+ export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS = [
360
+ {
361
+ surface: 'palette.background.paper',
362
+ reason: 'The canonical surface is deliberately MUI white.',
363
+ },
364
+ {
365
+ surface: 'spacing.unit',
366
+ reason: 'The canonical spacing rhythm deliberately retains MUI\'s 8px unit.',
367
+ },
368
+ {
369
+ surface: 'breakpoints.values',
370
+ reason: 'The canonical breakpoint values deliberately retain MUI\'s defaults.',
371
+ },
372
+ ...['subtitle2', 'body1', 'body2', 'button', 'caption'].map((variant) => ({
373
+ surface: `typography.${variant}.fontWeight`,
374
+ reason: `The canonical ${variant} weight deliberately equals MUI's numeric default.`,
375
+ })),
376
+ ];
@@ -36,4 +36,44 @@ describe('chart palette and formatters', () => {
36
36
  expect(formatCompact(12500, 'en-US')).toMatch(/12.5K|13K/);
37
37
  expect(createChartFormatters('fr-FR').ratio(1.5)).toContain('1');
38
38
  });
39
+
40
+ it('uses the theme categorical ramp when one is present', () => {
41
+ const ramp = ['series-1', 'series-2', 'series-3'];
42
+ const theme = {
43
+ palette: {
44
+ dataSeries: { categorical: ramp },
45
+ mode: 'light',
46
+ primary: { main: 'primary-main', light: 'primary-light' },
47
+ action: { disabled: 'action-disabled' },
48
+ text: { secondary: 'text-secondary' },
49
+ divider: 'divider',
50
+ },
51
+ };
52
+
53
+ expect(getNeutralChartPalette(theme).categorical).toEqual(ramp);
54
+ });
55
+
56
+ it('keeps the existing palette derivation when no ramp is present', () => {
57
+ const theme = {
58
+ palette: {
59
+ mode: 'light',
60
+ primary: { main: 'primary-main', light: 'primary-light' },
61
+ secondary: { main: 'secondary-main' },
62
+ info: { main: 'info-main' },
63
+ success: { main: 'success-main' },
64
+ warning: { main: 'warning-main' },
65
+ action: { disabled: 'action-disabled' },
66
+ text: { secondary: 'text-secondary' },
67
+ divider: 'divider',
68
+ },
69
+ };
70
+
71
+ expect(getNeutralChartPalette(theme).categorical).toEqual([
72
+ 'primary-main',
73
+ 'secondary-main',
74
+ 'info-main',
75
+ 'success-main',
76
+ 'warning-main',
77
+ ]);
78
+ });
39
79
  });
@@ -0,0 +1,103 @@
1
+ import { createTheme } from '@mui/material/styles';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { assertThemeComplete, createAppTheme } from '../src/index';
5
+ import { BASELINE_STATIC } from '../src/theme/tokens';
6
+
7
+ describe('createAppTheme', () => {
8
+ it('builds a complete baseline from only an app primary colour', () => {
9
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
10
+
11
+ expect(assertThemeComplete(theme).findings).toEqual([]);
12
+ expect(theme.typography.body1.fontSize).toBe('14px');
13
+ expect(theme.shape.borderRadius).toBe(3);
14
+ expect(theme.components.MuiPaper.defaultProps).toMatchObject({ elevation: 0, variant: 'outlined' });
15
+ expect(theme.palette.dataSeries.categorical).toHaveLength(6);
16
+ expect(theme.palette.primary.light).not.toBe(createTheme().palette.primary.light);
17
+ // MUI's createTheme(options, ...args) only augments the FIRST argument's
18
+ // palette; every later arg is deep-merged raw, so a status entry missing
19
+ // `main`/`contrastText` silently keeps MUI's stock hue for color="success"
20
+ // etc. -- main/contrastText must equal the canonical fill/fillText tones.
21
+ for (const status of ['success', 'warning', 'error', 'info']) {
22
+ expect(theme.palette[status].main).toBe(theme.palette[status].fill);
23
+ expect(theme.palette[status].contrastText).toBe(theme.palette[status].fillText);
24
+ }
25
+ });
26
+
27
+ it('throws when palette.primary is missing', () => {
28
+ expect(() => createAppTheme()).toThrow(/palette\.primary.*required/i);
29
+ });
30
+
31
+ it('throws on a function in an app styleOverrides slot', () => {
32
+ expect(() => createAppTheme({
33
+ palette: { primary: { main: '#0F62FE' } },
34
+ components: { MuiButton: { styleOverrides: { root: () => ({ color: 'red' }) } } },
35
+ })).toThrow(/components\.MuiButton\.styleOverrides\.root.*object.*variants/i);
36
+ });
37
+
38
+ it('computes component values from the final app status palette', () => {
39
+ const theme = createAppTheme({
40
+ palette: {
41
+ primary: { main: '#0F62FE' },
42
+ success: { main: '#14532D', text: '#14532D', bg: '#DCFCE7' },
43
+ },
44
+ });
45
+
46
+ expect(theme.components.MuiAlert.styleOverrides.standardSuccess).toEqual({
47
+ color: '#14532D',
48
+ backgroundColor: '#DCFCE7',
49
+ '& .MuiAlert-icon': { color: '#14532D' },
50
+ });
51
+ });
52
+
53
+ it('deep-merges app object overrides without dropping baseline properties', () => {
54
+ const theme = createAppTheme({
55
+ palette: { primary: { main: '#0F62FE' } },
56
+ typography: { fontFamily: "'App Sans', sans-serif" },
57
+ components: { MuiButton: { styleOverrides: { root: { fontSize: '15px' } } } },
58
+ });
59
+
60
+ expect(theme.components.MuiButton.styleOverrides.root).toMatchObject({
61
+ minHeight: 40,
62
+ borderRadius: 3,
63
+ fontSize: '15px',
64
+ });
65
+ expect(theme.typography.body1.fontFamily).toBe("'App Sans', sans-serif");
66
+ expect(theme.components.MuiOutlinedInput.styleOverrides.root.height).toBeUndefined();
67
+ // variants is a sibling of styleOverrides (MUI matches it independently by
68
+ // props), not nested inside it -- see src/theme/tokens.js for why.
69
+ expect(theme.components.MuiOutlinedInput.variants).toContainEqual({
70
+ props: { multiline: false },
71
+ style: { height: 40 },
72
+ });
73
+ });
74
+
75
+ it('returns independent themes for runtime palettes in one process', () => {
76
+ const blue = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
77
+ const purple = createAppTheme({ palette: { primary: { main: '#432CA1' } } });
78
+
79
+ expect(blue).not.toBe(purple);
80
+ expect(blue.palette).not.toBe(purple.palette);
81
+ expect(blue.palette.dataSeries.categorical).not.toBe(purple.palette.dataSeries.categorical);
82
+ expect(blue.palette.primary.main).toBe('#0F62FE');
83
+ expect(purple.palette.primary.main).toBe('#432CA1');
84
+ expect(blue.palette.controlBorder.focus).not.toBe(purple.palette.controlBorder.focus);
85
+ });
86
+
87
+ it('contains no function anywhere in the baseline components tree', () => {
88
+ const functionPaths = [];
89
+ const visit = (value, path) => {
90
+ if (typeof value === 'function') functionPaths.push(path);
91
+ else if (value && typeof value === 'object') {
92
+ Object.entries(value).forEach(([key, child]) => visit(child, `${path}.${key}`));
93
+ }
94
+ };
95
+
96
+ visit(BASELINE_STATIC.components, 'baseline.components');
97
+ visit(
98
+ createAppTheme({ palette: { primary: { main: '#0F62FE' } } }).components,
99
+ 'theme.components',
100
+ );
101
+ expect(functionPaths).toEqual([]);
102
+ });
103
+ });
@@ -0,0 +1,63 @@
1
+ import { createTheme } from '@mui/material/styles';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import {
5
+ assertThemeComplete,
6
+ calculateContrastRatio,
7
+ createAppTheme,
8
+ } from '../src/index';
9
+
10
+ describe('theme completeness', () => {
11
+ it('finds untouched MUI surfaces and accepts createAppTheme', () => {
12
+ const bare = assertThemeComplete(createTheme());
13
+ const baseline = assertThemeComplete(
14
+ createAppTheme({ palette: { primary: { main: '#0F62FE' } } }),
15
+ );
16
+
17
+ expect(bare.findings.length).toBeGreaterThan(0);
18
+ expect(bare.findings.map(({ surface }) => surface)).toContain('shape.borderRadius');
19
+ expect(assertThemeComplete(createTheme({ components: { MuiButton: {} } })).findings
20
+ .map(({ surface }) => surface))
21
+ .toContain('components.MuiButton.styleOverrides.root.minHeight');
22
+ expect(baseline.findings).toEqual([]);
23
+ });
24
+
25
+ it('suppresses exactly one surface when an exemption includes a reason', () => {
26
+ const theme = createTheme();
27
+ const before = assertThemeComplete(theme).findings;
28
+ const after = assertThemeComplete(theme, {
29
+ exemptions: [{
30
+ surface: 'palette.background.paper',
31
+ reason: 'This application deliberately uses white as its surface.',
32
+ }],
33
+ }).findings;
34
+
35
+ expect(after).toHaveLength(before.length - 1);
36
+ expect(after.map(({ surface }) => surface)).not.toContain('palette.background.paper');
37
+ });
38
+
39
+ it('reports an exemption without a reason as its own finding', () => {
40
+ const result = assertThemeComplete(createTheme(), {
41
+ exemptions: [{ surface: 'shape.borderRadius' }],
42
+ });
43
+
44
+ expect(result.findings.map(({ surface }) => surface)).toContain('exemption.shape.borderRadius');
45
+ expect(result.findings.map(({ surface }) => surface)).toContain('shape.borderRadius');
46
+ });
47
+
48
+ it('meets status-on-tint and control-boundary contrast thresholds', () => {
49
+ const theme = createAppTheme({ palette: { primary: { main: '#8AB4F8' } } });
50
+
51
+ for (const status of ['success', 'warning', 'error', 'info', 'stale']) {
52
+ expect(calculateContrastRatio(theme.palette[status].text, theme.palette[status].bg)).toBeGreaterThanOrEqual(4.5);
53
+ }
54
+ for (const state of ['main', 'hover', 'focus', 'error']) {
55
+ expect(calculateContrastRatio(theme.palette.controlBorder[state], '#FFFFFF')).toBeGreaterThanOrEqual(3);
56
+ expect(calculateContrastRatio(theme.palette.controlBorder[state], theme.palette.background.default)).toBeGreaterThanOrEqual(3);
57
+ }
58
+
59
+ theme.palette.success.text = 'not-a-colour';
60
+ expect(assertThemeComplete(theme).findings.map(({ surface }) => surface))
61
+ .toContain('contrast.success.text-on-bg');
62
+ });
63
+ });