@dfosco/storyboard-react 4.0.0-beta.2 → 4.0.0-beta.20

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 (44) hide show
  1. package/package.json +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  5. package/src/canvas/CanvasPage.jsx +512 -235
  6. package/src/canvas/CanvasPage.module.css +9 -47
  7. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  8. package/src/canvas/PageSelector.jsx +102 -0
  9. package/src/canvas/PageSelector.module.css +93 -0
  10. package/src/canvas/PageSelector.test.jsx +104 -0
  11. package/src/canvas/canvasApi.js +4 -0
  12. package/src/canvas/canvasReloadGuard.js +37 -0
  13. package/src/canvas/canvasReloadGuard.test.js +27 -0
  14. package/src/canvas/componentIsolate.jsx +135 -0
  15. package/src/canvas/useCanvas.js +6 -2
  16. package/src/canvas/widgets/ComponentWidget.jsx +67 -9
  17. package/src/canvas/widgets/ComponentWidget.module.css +9 -6
  18. package/src/canvas/widgets/FigmaEmbed.jsx +26 -4
  19. package/src/canvas/widgets/FigmaEmbed.module.css +0 -7
  20. package/src/canvas/widgets/MarkdownBlock.jsx +94 -21
  21. package/src/canvas/widgets/MarkdownBlock.module.css +110 -2
  22. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  23. package/src/canvas/widgets/PrototypeEmbed.jsx +196 -40
  24. package/src/canvas/widgets/PrototypeEmbed.module.css +30 -3
  25. package/src/canvas/widgets/StickyNote.module.css +5 -0
  26. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  27. package/src/canvas/widgets/StoryWidget.jsx +471 -0
  28. package/src/canvas/widgets/StoryWidget.module.css +200 -0
  29. package/src/canvas/widgets/WidgetChrome.jsx +54 -18
  30. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  31. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  32. package/src/canvas/widgets/embedInteraction.test.jsx +155 -0
  33. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  34. package/src/canvas/widgets/index.js +2 -0
  35. package/src/canvas/widgets/pasteRules.js +295 -0
  36. package/src/canvas/widgets/pasteRules.test.js +474 -0
  37. package/src/canvas/widgets/widgetConfig.js +16 -5
  38. package/src/canvas/widgets/widgetConfig.test.js +31 -9
  39. package/src/context.jsx +138 -13
  40. package/src/hooks/useSceneData.js +4 -2
  41. package/src/story/StoryPage.jsx +152 -0
  42. package/src/story/StoryPage.module.css +73 -0
  43. package/src/vite/data-plugin.js +441 -58
  44. package/src/vite/data-plugin.test.js +405 -5
@@ -1,17 +1,34 @@
1
- import { useRef, useCallback, useState, useEffect } from 'react'
1
+ import { useRef, useCallback, useState, useEffect, useMemo } from 'react'
2
2
  import WidgetWrapper from './WidgetWrapper.jsx'
3
3
  import ResizeHandle from './ResizeHandle.jsx'
4
+ import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
4
5
  import styles from './ComponentWidget.module.css'
6
+ import overlayStyles from './embedOverlay.module.css'
5
7
 
6
8
  /**
7
9
  * Renders a live JSX export from a .canvas.jsx companion file.
8
- * Content is read-only (re-renders on HMR), only position and size are mutable.
9
- * Cannot be deleted from canvas only removed from source code.
10
+ *
11
+ * In dev mode (isLocalDev), each component is rendered inside an iframe
12
+ * via the /_storyboard/canvas/isolate middleware. This isolates broken
13
+ * components so they cannot crash the entire canvas page.
14
+ *
15
+ * In production, the component is rendered directly with an ErrorBoundary
16
+ * as a fallback safety net.
10
17
  *
11
18
  * Double-click the overlay to enter interactive mode (dropdowns, buttons work).
12
19
  * Click outside to exit interactive mode.
13
20
  */
14
- export default function ComponentWidget({ component: Component, width, height, onUpdate, resizable }) {
21
+ export default function ComponentWidget({
22
+ component: Component,
23
+ jsxModule,
24
+ exportName,
25
+ canvasTheme,
26
+ isLocalDev,
27
+ width,
28
+ height,
29
+ onUpdate,
30
+ resizable,
31
+ }) {
15
32
  const containerRef = useRef(null)
16
33
  const [interactive, setInteractive] = useState(false)
17
34
 
@@ -33,7 +50,21 @@ export default function ComponentWidget({ component: Component, width, height, o
33
50
  return () => document.removeEventListener('pointerdown', handlePointerDown)
34
51
  }, [interactive])
35
52
 
36
- if (!Component) return null
53
+ // Build iframe src for dev isolation
54
+ const iframeSrc = useMemo(() => {
55
+ if (!isLocalDev || !jsxModule || !exportName) return null
56
+ const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
57
+ const params = new URLSearchParams({
58
+ module: jsxModule,
59
+ export: exportName,
60
+ theme: canvasTheme || 'light',
61
+ })
62
+ return `${basePath}/_storyboard/canvas/isolate?${params}`
63
+ }, [isLocalDev, jsxModule, exportName, canvasTheme])
64
+
65
+ const useIframe = isLocalDev && iframeSrc
66
+
67
+ if (!useIframe && !Component) return null
37
68
 
38
69
  const sizeStyle = {}
39
70
  if (typeof width === 'number') sizeStyle.width = `${width}px`
@@ -43,13 +74,40 @@ export default function ComponentWidget({ component: Component, width, height, o
43
74
  <WidgetWrapper>
44
75
  <div ref={containerRef} className={styles.container} style={sizeStyle}>
45
76
  <div className={styles.content}>
46
- <Component />
77
+ {useIframe ? (
78
+ <iframe
79
+ src={iframeSrc}
80
+ className={styles.iframe}
81
+ title={exportName || 'Component widget'}
82
+ sandbox="allow-same-origin allow-scripts"
83
+ />
84
+ ) : Component ? (
85
+ <ComponentErrorBoundary name={exportName}>
86
+ <Component />
87
+ </ComponentErrorBoundary>
88
+ ) : null}
47
89
  </div>
48
90
  {!interactive && (
49
91
  <div
50
- className={styles.interactOverlay}
51
- onDoubleClick={enterInteractive}
52
- />
92
+ className={overlayStyles.interactOverlay}
93
+ onClick={(e) => {
94
+ // Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
95
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
96
+ enterInteractive()
97
+ }}
98
+ role="button"
99
+ tabIndex={0}
100
+ onKeyDown={(e) => {
101
+ if (e.key === 'Enter' || e.key === ' ') {
102
+ e.preventDefault()
103
+ e.stopPropagation()
104
+ enterInteractive()
105
+ }
106
+ }}
107
+ aria-label="Click to interact with component"
108
+ >
109
+ <span className={overlayStyles.interactHint}>Click to interact</span>
110
+ </div>
53
111
  )}
54
112
  {resizable && (
55
113
  <ResizeHandle
@@ -1,8 +1,11 @@
1
1
  .container {
2
2
  position: relative;
3
- overflow: auto;
3
+ overflow: hidden;
4
4
  min-width: 100px;
5
5
  min-height: 60px;
6
+ background: var(--bgColor-default, #ffffff);
7
+ width: 100%;
8
+ height: 100%;
6
9
  }
7
10
 
8
11
  .content {
@@ -10,9 +13,9 @@
10
13
  height: 100%;
11
14
  }
12
15
 
13
- .interactOverlay {
14
- position: absolute;
15
- inset: 0;
16
- z-index: 1;
17
- cursor: default;
16
+ .iframe {
17
+ display: block;
18
+ width: 100%;
19
+ height: 100%;
20
+ border: none;
18
21
  }
@@ -5,6 +5,7 @@ import { readProp } from './widgetProps.js'
5
5
  import { schemas } from './widgetConfig.js'
6
6
  import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
7
7
  import styles from './FigmaEmbed.module.css'
8
+ import overlayStyles from './embedOverlay.module.css'
8
9
 
9
10
  const figmaEmbedSchema = schemas['figma-embed']
10
11
 
@@ -126,9 +127,25 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
126
127
  </div>
127
128
  {!interactive && !expanded && (
128
129
  <div
129
- className={styles.dragOverlay}
130
- onDoubleClick={enterInteractive}
131
- />
130
+ className={overlayStyles.interactOverlay}
131
+ onClick={(e) => {
132
+ // Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
133
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
134
+ enterInteractive()
135
+ }}
136
+ role="button"
137
+ tabIndex={0}
138
+ onKeyDown={(e) => {
139
+ if (e.key === 'Enter' || e.key === ' ') {
140
+ e.preventDefault()
141
+ e.stopPropagation()
142
+ enterInteractive()
143
+ }
144
+ }}
145
+ aria-label="Click to interact with Figma embed"
146
+ >
147
+ <span className={overlayStyles.interactHint}>Click to interact</span>
148
+ </div>
132
149
  )}
133
150
  </>
134
151
  ) : (
@@ -171,8 +188,13 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
171
188
  style={expanded && embedUrl ? undefined : { display: 'none' }}
172
189
  onClick={() => setExpanded(false)}
173
190
  onPointerDown={(e) => e.stopPropagation()}
174
- onKeyDown={(e) => e.stopPropagation()}
191
+ onKeyDown={(e) => {
192
+ e.stopPropagation()
193
+ if (e.key === 'Escape') setExpanded(false)
194
+ }}
175
195
  onWheel={(e) => e.stopPropagation()}
196
+ tabIndex={-1}
197
+ ref={(el) => { if (el && expanded) el.focus() }}
176
198
  >
177
199
  <div
178
200
  ref={modalContainerRef}
@@ -47,13 +47,6 @@
47
47
  display: block;
48
48
  }
49
49
 
50
- .dragOverlay {
51
- position: absolute;
52
- inset: 0;
53
- z-index: 1;
54
- cursor: grab;
55
- }
56
-
57
50
  .resizeHandle {
58
51
  position: absolute;
59
52
  bottom: 0;
@@ -1,33 +1,72 @@
1
- import { useState, useRef, useEffect, useCallback } from 'react'
1
+ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
2
+ import { remark } from 'remark'
3
+ import remarkGfm from 'remark-gfm'
4
+ import remarkHtml from 'remark-html'
2
5
  import WidgetWrapper from './WidgetWrapper.jsx'
3
- import { readProp, markdownSchema } from './widgetProps.js'
6
+ import ResizeHandle from './ResizeHandle.jsx'
7
+ import { readProp } from './widgetProps.js'
8
+ import { schemas } from './widgetConfig.js'
4
9
  import styles from './MarkdownBlock.module.css'
5
10
 
11
+ const markdownSchema = schemas['markdown']
12
+
6
13
  /**
7
- * Renders markdown as plain HTML using a minimal built-in converter.
14
+ * Renders markdown to HTML using remark with GitHub Flavored Markdown support.
8
15
  */
9
16
  function renderMarkdown(text) {
10
17
  if (!text) return ''
11
- return text
12
- .replace(/^### (.+)$/gm, '<h3>$1</h3>')
13
- .replace(/^## (.+)$/gm, '<h2>$1</h2>')
14
- .replace(/^# (.+)$/gm, '<h1>$1</h1>')
15
- .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
16
- .replace(/\*(.+?)\*/g, '<em>$1</em>')
17
- .replace(/`(.+?)`/g, '<code>$1</code>')
18
- .replace(/^- (.+)$/gm, '<li>$1</li>')
19
- .replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
20
- .replace(/\n\n/g, '</p><p>')
21
- .replace(/\n/g, '<br>')
22
- .replace(/^(.+)$/gm, (line) => {
23
- if (line.startsWith('<')) return line
24
- return `<p>${line}</p>`
25
- })
18
+ const result = remark()
19
+ .use(remarkGfm)
20
+ .use(remarkHtml, { sanitize: false })
21
+ .processSync(text)
22
+ return String(result)
23
+ }
24
+
25
+ /**
26
+ * Post-process rendered HTML to syntax-highlight fenced code blocks.
27
+ * remark-html outputs <pre><code class="language-xxx">...</code></pre>.
28
+ * We replace the code content with highlight.js output.
29
+ */
30
+ let hljsPromise = null
31
+ function getHljs() {
32
+ if (!hljsPromise) {
33
+ hljsPromise = import('@dfosco/storyboard-core/inspector/highlighter').then((mod) => mod)
34
+ }
35
+ return hljsPromise
36
+ }
37
+
38
+ async function highlightCodeBlocks(html) {
39
+ if (!html.includes('<code class="language-')) return html
40
+ const { createInspectorHighlighter } = await getHljs()
41
+ const hl = await createInspectorHighlighter()
42
+ return html.replace(
43
+ /<pre><code class="language-(\w+)">([\s\S]*?)<\/code><\/pre>/g,
44
+ (match, lang, code) => {
45
+ try {
46
+ // Decode all HTML entities that remark-html may produce
47
+ const decoded = code
48
+ .replace(/&#x3C;/gi, '<')
49
+ .replace(/&#x3E;/gi, '>')
50
+ .replace(/&#x26;/gi, '&')
51
+ .replace(/&#x22;/gi, '"')
52
+ .replace(/&#x27;/gi, "'")
53
+ .replace(/&lt;/g, '<')
54
+ .replace(/&gt;/g, '>')
55
+ .replace(/&quot;/g, '"')
56
+ .replace(/&amp;/g, '&')
57
+ // codeToHtml returns a full <pre style="bg;fg"><code>...</code></pre>
58
+ return hl.codeToHtml(decoded, { lang })
59
+ } catch {
60
+ return match
61
+ }
62
+ }
63
+ )
26
64
  }
27
65
 
28
- export default function MarkdownBlock({ props, onUpdate }) {
66
+ export default function MarkdownBlock({ props, onUpdate, resizable }) {
29
67
  const content = readProp(props, 'content', markdownSchema)
30
68
  const width = readProp(props, 'width', markdownSchema)
69
+ const height = props?.height
31
70
  const canEdit = typeof onUpdate === 'function'
32
71
  const [editing, setEditing] = useState(false)
33
72
  const editingActive = canEdit && editing
@@ -35,6 +74,32 @@ export default function MarkdownBlock({ props, onUpdate }) {
35
74
  const blockRef = useRef(null)
36
75
  const [editHeight, setEditHeight] = useState(null)
37
76
 
77
+ const handleResize = useCallback((w, h) => {
78
+ onUpdate?.({ width: w, height: h })
79
+ }, [onUpdate])
80
+
81
+ const rawHtml = useMemo(() => renderMarkdown(content), [content])
82
+ const [renderedHtml, setRenderedHtml] = useState(rawHtml)
83
+
84
+ // Re-highlight when theme changes
85
+ const [themeKey, setThemeKey] = useState(0)
86
+ useEffect(() => {
87
+ function onThemeChanged() { setThemeKey((k) => k + 1) }
88
+ document.addEventListener('storyboard:theme:changed', onThemeChanged)
89
+ return () => document.removeEventListener('storyboard:theme:changed', onThemeChanged)
90
+ }, [])
91
+
92
+ // Async-highlight code blocks after initial render or theme change
93
+ useEffect(() => {
94
+ setRenderedHtml(rawHtml)
95
+ if (!rawHtml.includes('<code class="language-')) return
96
+ let cancelled = false
97
+ highlightCodeBlocks(rawHtml).then((highlighted) => {
98
+ if (!cancelled) setRenderedHtml(highlighted)
99
+ })
100
+ return () => { cancelled = true }
101
+ }, [rawHtml, themeKey])
102
+
38
103
  const handleContentChange = useCallback((e) => {
39
104
  onUpdate?.({ content: e.target.value })
40
105
  }, [onUpdate])
@@ -67,7 +132,7 @@ export default function MarkdownBlock({ props, onUpdate }) {
67
132
  <div
68
133
  ref={blockRef}
69
134
  className={styles.block}
70
- style={{ width, minHeight: editHeight || undefined }}
135
+ style={{ width, ...(height ? { height, overflow: 'auto' } : {}), minHeight: editHeight || undefined }}
71
136
  >
72
137
  {editingActive ? (
73
138
  <textarea
@@ -97,12 +162,20 @@ export default function MarkdownBlock({ props, onUpdate }) {
97
162
  tabIndex={canEdit ? 0 : undefined}
98
163
  onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') setEditing(true) } : undefined}
99
164
  dangerouslySetInnerHTML={{
100
- __html: renderMarkdown(content) || (canEdit
165
+ __html: renderedHtml || (canEdit
101
166
  ? '<p class="placeholder">Double-click to edit…</p>'
102
167
  : '<p class="placeholder">No content</p>'),
103
168
  }}
104
169
  />
105
170
  )}
171
+ {resizable && (
172
+ <ResizeHandle
173
+ targetRef={blockRef}
174
+ minWidth={200}
175
+ minHeight={60}
176
+ onResize={handleResize}
177
+ />
178
+ )}
106
179
  </div>
107
180
  </WidgetWrapper>
108
181
  )
@@ -1,4 +1,5 @@
1
1
  .block {
2
+ position: relative;
2
3
  min-height: 80px;
3
4
  --sb--markdown-bg: var(--bgColor-default, #ffffff);
4
5
  --sb--markdown-fg: var(--fgColor-default, #1f2328);
@@ -52,17 +53,124 @@
52
53
  background: var(--bgColor-neutral-muted, #afb8c133);
53
54
  padding: 2px 5px;
54
55
  border-radius: 4px;
55
- font-size: 13px;
56
- font-family: ui-monospace, monospace;
56
+ font-size: 12px;
57
+ font-weight: 400;
58
+ font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
57
59
  }
58
60
 
59
61
  .preview ul {
60
62
  margin: 0 0 8px;
61
63
  padding-left: 20px;
64
+ list-style-type: disc;
62
65
  }
63
66
 
64
67
  .preview li {
65
68
  margin: 0 0 2px;
69
+ display: list-item;
70
+ }
71
+
72
+ .preview ol {
73
+ margin: 0 0 8px;
74
+ padding-left: 20px;
75
+ list-style-type: decimal;
76
+ }
77
+
78
+ /* GFM: Task lists */
79
+ .preview input[type="checkbox"] {
80
+ margin-right: 6px;
81
+ pointer-events: none;
82
+ }
83
+
84
+ .preview li:has(input[type="checkbox"]) {
85
+ list-style: none;
86
+ margin-left: -20px;
87
+ }
88
+
89
+ /* GFM: Strikethrough */
90
+ .preview del {
91
+ text-decoration: line-through;
92
+ color: var(--sb--markdown-muted);
93
+ }
94
+
95
+ /* GFM: Tables */
96
+ .preview table {
97
+ border-collapse: collapse;
98
+ margin: 8px 0;
99
+ width: 100%;
100
+ font-size: 13px;
101
+ }
102
+
103
+ .preview th,
104
+ .preview td {
105
+ border: 1px solid var(--borderColor-default, #d0d7de);
106
+ padding: 6px 12px;
107
+ text-align: left;
108
+ }
109
+
110
+ .preview th {
111
+ background: var(--bgColor-muted, #f6f8fa);
112
+ font-weight: 600;
113
+ }
114
+
115
+ /* GFM: Autolinks */
116
+ .preview a {
117
+ color: var(--sb--markdown-accent);
118
+ text-decoration: none;
119
+ }
120
+
121
+ .preview a:hover {
122
+ text-decoration: underline;
123
+ }
124
+
125
+ /* Code blocks */
126
+ .preview pre {
127
+ padding: 12px 16px;
128
+ border-radius: 6px;
129
+ border: 1px solid var(--borderColor-muted, #d8dee4);
130
+ overflow-x: auto;
131
+ margin: 8px 0;
132
+ background: var(--bgColor-neutral-muted, #afb8c133);
133
+ line-height: 1.4;
134
+ }
135
+
136
+ /* When inspector highlighter sets inline background, let it through */
137
+ .preview pre[style] {
138
+ border-radius: 6px;
139
+ border: 1px solid var(--borderColor-muted, #d8dee4);
140
+ overflow-x: auto;
141
+ margin: 8px 0;
142
+ }
143
+
144
+ .preview pre code {
145
+ background: none;
146
+ padding: 0;
147
+ font-size: 12px;
148
+ font-weight: 400;
149
+ font-family: "Ioskeley Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
150
+ white-space: pre;
151
+ word-break: normal;
152
+ overflow-wrap: normal;
153
+ display: block;
154
+ }
155
+
156
+ /* Suppress line numbers from inspector highlighter's .line spans */
157
+ .preview :global(.line::before) {
158
+ display: none !important;
159
+ }
160
+
161
+ /* Blockquotes */
162
+ .preview blockquote {
163
+ border-left: 4px solid var(--borderColor-default, #d0d7de);
164
+ margin: 8px 0;
165
+ padding: 4px 16px;
166
+ color: var(--sb--markdown-muted);
167
+ }
168
+
169
+ /* Horizontal rules */
170
+ .preview hr {
171
+ border: none;
172
+ border-top: 1px solid var(--borderColor-default, #d0d7de);
173
+ margin: 16px 0;
66
174
  }
67
175
 
68
176
  .preview :global(.placeholder) {
@@ -50,4 +50,43 @@ describe('MarkdownBlock', () => {
50
50
 
51
51
  expect(setData).toHaveBeenCalledWith('text/plain', '**Hello**\n- item')
52
52
  })
53
+
54
+ describe('GitHub Flavored Markdown', () => {
55
+ it('renders tables', () => {
56
+ const markdown = `| Name | Age |
57
+ | --- | --- |
58
+ | Alice | 30 |`
59
+ const { container } = render(<MarkdownBlock props={{ content: markdown, width: 420 }} />)
60
+
61
+ expect(container.querySelector('table')).not.toBeNull()
62
+ expect(container.querySelector('th')).not.toBeNull()
63
+ expect(screen.getByText('Alice')).toBeTruthy()
64
+ })
65
+
66
+ it('renders task lists', () => {
67
+ const markdown = `- [x] Done
68
+ - [ ] Todo`
69
+ const { container } = render(<MarkdownBlock props={{ content: markdown, width: 420 }} />)
70
+
71
+ const checkboxes = container.querySelectorAll('input[type="checkbox"]')
72
+ expect(checkboxes).toHaveLength(2)
73
+ expect(checkboxes[0].checked).toBe(true)
74
+ expect(checkboxes[1].checked).toBe(false)
75
+ })
76
+
77
+ it('renders strikethrough', () => {
78
+ const { container } = render(<MarkdownBlock props={{ content: '~~deleted~~', width: 420 }} />)
79
+
80
+ expect(container.querySelector('del')).not.toBeNull()
81
+ expect(screen.getByText('deleted')).toBeTruthy()
82
+ })
83
+
84
+ it('renders autolinks', () => {
85
+ const { container } = render(<MarkdownBlock props={{ content: 'https://github.com', width: 420 }} />)
86
+
87
+ const link = container.querySelector('a')
88
+ expect(link).not.toBeNull()
89
+ expect(link.href).toBe('https://github.com/')
90
+ })
91
+ })
53
92
  })