@visualli/react 0.1.7 → 0.2.1

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 (49) hide show
  1. package/README.md +13 -5
  2. package/dist/index.cjs +1900 -2033
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +354 -97
  5. package/dist/index.d.ts +354 -97
  6. package/dist/index.js +2005 -2095
  7. package/dist/index.js.map +1 -1
  8. package/fonts/Caveat-Variable.ttf +0 -0
  9. package/licenses/visualli-design-system/LICENSE +9 -0
  10. package/licenses/visualli-design-system/NOTICE.md +6 -0
  11. package/package.json +9 -4
  12. package/src/VisualliCanvas.tsx +452 -409
  13. package/src/VisualliRenderer.tsx +90 -112
  14. package/src/components/A11yLayer.tsx +73 -0
  15. package/src/components/KonvaContainer.tsx +28 -237
  16. package/src/components/KonvaContainerLabelLayer.tsx +41 -0
  17. package/src/components/KonvaContainerLayer.tsx +34 -33
  18. package/src/components/KonvaEdge.tsx +25 -240
  19. package/src/components/KonvaEdgeLayer.tsx +69 -49
  20. package/src/components/KonvaNode.tsx +21 -156
  21. package/src/components/KonvaNodeLayer.tsx +102 -34
  22. package/src/components/KonvaStage.tsx +17 -3
  23. package/src/components/NavigationStack.tsx +16 -159
  24. package/src/components/Overlays.tsx +327 -0
  25. package/src/components/ZoomControls.tsx +3 -107
  26. package/src/components/useFrames.ts +30 -0
  27. package/src/context/VisualliContext.tsx +3 -3
  28. package/src/design/choreography.ts +157 -0
  29. package/src/design/containers.ts +150 -0
  30. package/src/design/design.ts +115 -0
  31. package/src/design/drawing.ts +303 -0
  32. package/src/design/measure.ts +34 -0
  33. package/src/design/runtime.ts +154 -0
  34. package/src/design/useDesign.ts +38 -0
  35. package/src/generated/specCss.ts +15 -0
  36. package/src/hooks/index.ts +0 -2
  37. package/src/hooks/useKonvaRenderer.ts +13 -18
  38. package/src/hooks/useLayerChoreography.ts +159 -0
  39. package/src/hooks/useViewportNodes.ts +0 -1
  40. package/src/index.ts +22 -22
  41. package/src/stores/stores.ts +2 -2
  42. package/src/stores/useViewportStore.ts +12 -2
  43. package/src/animations/index.ts +0 -2
  44. package/src/animations/konvaLayerTransition.ts +0 -223
  45. package/src/components/SketchyBoxKonva.tsx +0 -121
  46. package/src/config/blobShapes.ts +0 -204
  47. package/src/config/index.ts +0 -21
  48. package/src/config/textScaling.ts +0 -69
  49. package/src/hooks/useKonvaLayerTransition.ts +0 -221
@@ -16,13 +16,20 @@
16
16
  // • Delegates all canvas/navigation/zoom logic to VisualliCanvas
17
17
 
18
18
  import React, { useEffect, useRef, useState, useMemo } from 'react';
19
- import type { VisualliDocument, VisualliLayer, FlatNode } from '@visualli/core';
20
- import { parseVisualliFile } from '@visualli/core';
19
+ import type { VisualliDocument, Comfort, ThemeInput, TopicName } from '@visualli/core';
20
+ import { blobPath, parseVisualliFile, shapeForLevel } from '@visualli/core';
21
21
  import VisualliCanvas from './VisualliCanvas';
22
+ import { useDesign } from './design/useDesign';
23
+ import { ensureDesignSystemStyles, type DesignSystemAssets } from './design/runtime';
22
24
 
23
25
  // ── Types ─────────────────────────────────────────────────────────────────────
24
26
 
25
- export type VisualliTheme = 'dark' | 'light' | 'auto';
27
+ /**
28
+ * Any of the 8 design-system themes ('light', 'dark', 'focus-light', 'focus-dark', 'colorsafe-light',
29
+ * 'colorsafe-dark', 'contrast-light', 'contrast-dark'), a family ('focus' | 'colorsafe' | 'contrast'),
30
+ * or 'auto' (follow the reader's colour scheme). 'light' | 'dark' | 'auto' are the pre-0.2 values and keep working.
31
+ */
32
+ export type VisualliTheme = ThemeInput;
26
33
 
27
34
  export interface VisualliRendererProps {
28
35
  /**
@@ -50,19 +57,40 @@ export interface VisualliRendererProps {
50
57
  useWorker?: boolean;
51
58
 
52
59
  /**
53
- * Colour theme.
54
- * - 'dark' → always dark background
55
- * - 'light' → always light background
56
- * - 'auto' → follows the OS/browser prefers-color-scheme
60
+ * Colour theme (see VisualliTheme).
61
+ * - 'auto' follows prefers-color-scheme (and switches to high contrast under forced-colors)
57
62
  * @default 'light'
58
63
  */
59
64
  theme?: VisualliTheme;
60
65
 
66
+ /** Comfort settings: readable type, larger text, reduced motion ('system' follows the OS). */
67
+ comfort?: Comfort;
68
+
69
+ /** Switch to the high-contrast theme under forced-colors. @default true */
70
+ respectForcedColors?: boolean;
71
+
72
+ /**
73
+ * Interaction layout: 'auto' follows the reader's input (touch -> bottom-sheet peek and larger
74
+ * controls, pointer -> floating peek on hover); force one with 'touch' or 'pointer'.
75
+ * @default 'auto'
76
+ */
77
+ layout?: 'auto' | 'touch' | 'pointer';
78
+
79
+ /** Where the zoom / fit controls sit: the design system's 'bottom-right' (default) or the pre-0.2 'top-right'. */
80
+ /**
81
+ * The map is the page (an app such as Visualli's web app): it takes every touch gesture, so the browser never
82
+ * zooms or scrolls the page — the design system's `.vi-map.is-app` (`touch-action: none`). Leave it off when the
83
+ * map is embedded in a scrolling page: one finger then still scrolls the page, two fingers pinch the map.
84
+ */
85
+ app?: boolean;
86
+ /** Where the zoom / fit controls sit: 'top-right' (default, the design system's placement) or 'bottom-right'. */
87
+ controlsPosition?: 'top-right' | 'bottom-right';
88
+
61
89
  /**
62
90
  * Enable chromatic immersion background effect.
63
91
  * When true:
64
- * - Root layer (level 0) shows base background color
65
- * - Child layers show parent node's color as semi-transparent background
92
+ * - Root layer (level 0) shows the plain canvas
93
+ * - Child layers are tinted with the parent idea's topic colour
66
94
  * @default false
67
95
  */
68
96
  chromaticImmersion?: boolean;
@@ -81,6 +109,12 @@ export interface VisualliRendererProps {
81
109
  */
82
110
  height?: string | number;
83
111
 
112
+ /** Directory URL the bundled Caveat font is served from (defaults to the copy in the npm package, via jsDelivr). */
113
+ fontBaseUrl?: DesignSystemAssets['fontBaseUrl'];
114
+
115
+ /** Set false when you load Kalam and Atkinson Hyperlegible yourself. */
116
+ loadWebFonts?: DesignSystemAssets['loadWebFonts'];
117
+
84
118
  /** Additional CSS class on the root element. */
85
119
  className?: string;
86
120
 
@@ -88,116 +122,42 @@ export interface VisualliRendererProps {
88
122
  style?: React.CSSProperties;
89
123
  }
90
124
 
91
- // ── System dark-mode hook ─────────────────────────────────────────────────────
92
-
93
- function useSystemDark(): boolean {
94
- const mq = typeof window !== 'undefined'
95
- ? window.matchMedia('(prefers-color-scheme: dark)')
96
- : null;
97
-
98
- const [dark, setDark] = useState<boolean>(mq?.matches ?? false);
99
-
100
- useEffect(() => {
101
- if (!mq) return;
102
- const handler = (e: MediaQueryListEvent) => setDark(e.matches);
103
- mq.addEventListener('change', handler);
104
- return () => mq.removeEventListener('change', handler);
105
- }, [mq]);
106
-
107
- return dark;
108
- }
109
-
110
- // ── Font Injection Hook ───────────────────────────────────────────────────────
111
-
112
- function useFontInjection() {
113
- useEffect(() => {
114
- if (typeof document === 'undefined') return;
115
-
116
- const links = [
117
- 'https://fonts.googleapis.com',
118
- 'https://fonts.gstatic.com',
119
- 'https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800&family=Nunito+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300&display=swap',
120
- 'https://fonts.googleapis.com/css2?family=Playpen+Sans:wght@300;400;600;800&family=Story+Script:wght@400&display=swap'
121
- ];
122
-
123
- links.forEach((href, i) => {
124
- const id = `visualli-font-${i}`;
125
- if (!document.getElementById(id)) {
126
- const link = document.createElement('link');
127
- link.id = id;
128
- link.rel = i < 2 ? 'preconnect' : 'stylesheet';
129
- link.href = href;
130
- if (i === 1) link.crossOrigin = 'anonymous';
131
- document.head.appendChild(link);
132
- }
133
- });
134
- }, []);
135
- }
136
-
137
125
  // ── Empty / Loading / Error states ───────────────────────────────────────────
126
+ // A still note in the middle of the map: an idea's blob in a topic colour, a title and a line of text — the design
127
+ // system's shapes, tokens and type styles only, and no motion of its own (the product's own state screens live in
128
+ // Visualli's apps).
138
129
 
139
- // ── UI State Components ───────────────────────────────────────────────────────
130
+ const stateBox: React.CSSProperties = { display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', gap: 'var(--space-3)', padding: 'var(--space-7)', boxSizing: 'border-box', textAlign: 'center', color: 'var(--ink-muted)', background: 'var(--canvas)' };
140
131
 
141
- function EmptyState({ isDark }: { isDark: boolean }) {
142
- const bg = isDark ? '#131311' : '#F5F3EF';
143
- const fg = isDark ? 'rgba(240,237,230,0.35)' : 'rgba(26,26,24,0.35)';
144
- const bd = isDark ? '#2A2A28' : '#DDD9D0';
145
- const code = { fontFamily: 'monospace', fontSize: 12, padding: '1px 4px', borderRadius: 4, background: isDark ? '#222220' : '#E8E4DC', border: `1px solid ${bd}` };
132
+ function StateNote({ topic, title, children, role }: { topic: TopicName; title: string; children?: React.ReactNode; role?: 'status' | 'alert' }) {
146
133
  return (
147
- <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', gap: 16, background: bg, color: fg, fontFamily: "'Nunito', system-ui, sans-serif" }}>
148
- <svg width="48" height="48" viewBox="0 0 48 48" fill="none" stroke={fg} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
149
- <rect x="10" y="6" width="28" height="36" rx="3" />
150
- <line x1="16" y1="16" x2="32" y2="16" />
151
- <line x1="16" y1="22" x2="32" y2="22" />
152
- <line x1="16" y1="28" x2="26" y2="28" />
134
+ <div className="vi-map" style={stateBox} role={role} aria-live={role === 'status' ? 'polite' : undefined}>
135
+ <svg width="64" height="52" viewBox="-32 -26 64 52" aria-hidden="true">
136
+ <path d={blobPath(shapeForLevel(0), 26, 20)} fill={`var(--topic-${topic})`} stroke={`var(--topic-${topic}-ring)`} strokeWidth="2" />
153
137
  </svg>
154
- <div style={{ textAlign: 'center', lineHeight: 1.7 }}>
155
- <p style={{ margin: 0, fontSize: 15, fontWeight: 600, color: isDark ? 'rgba(240,237,230,0.7)' : 'rgba(26,26,24,0.7)' }}>
156
- No .visualli file provided
157
- </p>
158
- <p style={{ margin: '4px 0 0', fontSize: 13 }}>
159
- Pass a <code style={code}>visualliFile</code> or <code style={code}>visualliString</code> prop.
160
- </p>
161
- </div>
138
+ <p className="vi-text-label" style={{ margin: 0, color: 'var(--ink)' }}>{title}</p>
139
+ {children && <div className="vi-text-body-sm" style={{ margin: 0, maxWidth: 480, overflowWrap: 'anywhere' }}>{children}</div>}
162
140
  </div>
163
141
  );
164
142
  }
165
143
 
166
- function LoadingState({ isDark }: { isDark: boolean }) {
167
- const bg = isDark ? '#131311' : '#F5F3EF';
168
- const fg = isDark ? 'rgba(240,237,230,0.5)' : 'rgba(26,26,24,0.5)';
169
- const dot = isDark ? 'rgba(240,237,230,0.6)' : 'rgba(26,26,24,0.45)';
144
+ function EmptyState() {
170
145
  return (
171
- <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', gap: 16, background: bg, color: fg, fontFamily: "'Nunito', system-ui, sans-serif" }}>
172
- <div style={{ display: 'flex', gap: 8 }}>
173
- {[0, 1, 2].map(i => (
174
- <div key={i} style={{ width: 8, height: 8, borderRadius: '50%', background: dot, animation: `vr-pulse 1.2s ease-in-out ${i * 0.2}s infinite` }} />
175
- ))}
176
- </div>
177
- <style>{`@keyframes vr-pulse{0%,80%,100%{opacity:.2;transform:scale(.9)}40%{opacity:1;transform:scale(1.1)}}`}</style>
178
- <p style={{ margin: 0, fontSize: 14 }}>Loading .visualli file…</p>
179
- </div>
146
+ <StateNote topic="stone" title="Nothing to show yet">
147
+ Pass a <code style={{ fontFamily: 'var(--font-mono)' }}>visualliFile</code> or <code style={{ fontFamily: 'var(--font-mono)' }}>visualliString</code>.
148
+ </StateNote>
180
149
  );
181
150
  }
182
151
 
183
- function ErrorState({ message, isDark }: { message: string; isDark: boolean }) {
184
- const bg = isDark ? '#131311' : '#F5F3EF';
185
- const bd = isDark ? '#3a1f1f' : '#F0CECE';
186
- const card = isDark ? '#1e1212' : '#FFF5F5';
187
- const head = isDark ? '#F87171' : '#DC2626';
188
- const body = isDark ? 'rgba(248,113,113,0.8)' : 'rgba(180,40,40,0.8)';
152
+ function LoadingState() {
153
+ return <StateNote topic="teal" title="Opening the map…" role="status" />;
154
+ }
155
+
156
+ function ErrorState({ message }: { message: string }) {
189
157
  return (
190
- <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', padding: '24px', background: bg, fontFamily: "'Nunito', system-ui, sans-serif" }}>
191
- <div style={{ maxWidth: 480, width: '100%', padding: '20px 24px', borderRadius: 12, border: `1px solid ${bd}`, background: card }}>
192
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
193
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={head} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
194
- <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
195
- </svg>
196
- <span style={{ fontSize: 15, fontWeight: 700, color: head }}>Failed to parse .visualli file</span>
197
- </div>
198
- <p style={{ margin: 0, fontSize: 13, color: body, lineHeight: 1.6, wordBreak: 'break-word', fontFamily: 'monospace' }}>{message}</p>
199
- </div>
200
- </div>
158
+ <StateNote topic="berry" title="This map couldn't be opened" role="alert">
159
+ <code style={{ fontFamily: 'var(--font-mono)' }}>{message}</code>
160
+ </StateNote>
201
161
  );
202
162
  }
203
163
 
@@ -215,6 +175,7 @@ self.onmessage = function(e) {
215
175
  layers: new Map(),
216
176
  layersByLevel: new Map(),
217
177
  rootLayer: null,
178
+ extensions: {},
218
179
  };
219
180
  for (var i = 0; i < lines.length; i++) {
220
181
  var line = lines[i].trim();
@@ -230,6 +191,8 @@ self.onmessage = function(e) {
230
191
  if (!doc.layersByLevel.has(obj.level)) doc.layersByLevel.set(obj.level, []);
231
192
  doc.layersByLevel.get(obj.level).push(obj);
232
193
  if (obj.level === 0) doc.rootLayer = obj;
194
+ } else if (obj.type === 'extension' && typeof obj.id === 'string') {
195
+ doc.extensions[obj.id] = Array.isArray(obj.data) ? obj.data : [];
233
196
  }
234
197
  }
235
198
  if (!doc.meta) throw new Error('Missing required meta section');
@@ -368,16 +331,23 @@ export default function VisualliRenderer({
368
331
  visualliString,
369
332
  visualliFile,
370
333
  theme = 'light',
334
+ comfort,
335
+ respectForcedColors,
336
+ layout,
337
+ controlsPosition,
338
+ app,
371
339
  width = '100%',
372
340
  height = '100%',
373
341
  useWorker = true,
374
342
  chromaticImmersion = false,
343
+ fontBaseUrl,
344
+ loadWebFonts,
375
345
  className,
376
346
  style,
377
347
  }: VisualliRendererProps) {
378
- useFontInjection();
379
- const systemDark = useSystemDark();
380
- const isDark = theme === 'dark' ? true : theme === 'light' ? false : systemDark;
348
+ // Resolve once here so the empty / loading / error states carry the same theme attributes as the map.
349
+ const design = useDesign({ theme, comfort, respectForcedColors });
350
+ useState(() => { ensureDesignSystemStyles({ fontBaseUrl, loadWebFonts }); return true; });
381
351
 
382
352
  const cssWidth = typeof width === 'number' ? `${width}px` : width;
383
353
  const cssHeight = typeof height === 'number' ? `${height}px` : height;
@@ -396,21 +366,22 @@ export default function VisualliRenderer({
396
366
  }, [parseState]);
397
367
 
398
368
  const wrapperStyle: React.CSSProperties = { width: cssWidth, height: cssHeight, overflow: 'hidden', ...style };
369
+ const attrs = design.attrs;
399
370
 
400
371
  // ── Empty state ─────────────────────────────────────────────────────────────
401
372
  if (!visualliString && !visualliFile) {
402
- return <div className={className} style={wrapperStyle}><EmptyState isDark={isDark} /></div>;
373
+ return <div className={className} {...attrs} style={wrapperStyle}><EmptyState /></div>;
403
374
  }
404
375
 
405
376
  // ── Loading state ───────────────────────────────────────────────────────────
406
377
  if (fileReading || parseState.status === 'loading') {
407
- return <div className={className} style={wrapperStyle}><LoadingState isDark={isDark} /></div>;
378
+ return <div className={className} {...attrs} style={wrapperStyle}><LoadingState /></div>;
408
379
  }
409
380
 
410
381
  // ── Error state ─────────────────────────────────────────────────────────────
411
382
  if (fileReadError || parseState.status === 'error') {
412
383
  const msg = fileReadError ?? (parseState.status === 'error' ? parseState.message : 'Unknown error');
413
- return <div className={className} style={wrapperStyle}><ErrorState message={msg} isDark={isDark} /></div>;
384
+ return <div className={className} {...attrs} style={wrapperStyle}><ErrorState message={msg} /></div>;
414
385
  }
415
386
 
416
387
  // ── Canvas ──────────────────────────────────────────────────────────────────
@@ -418,7 +389,14 @@ export default function VisualliRenderer({
418
389
  <div className={className} style={wrapperStyle}>
419
390
  <VisualliCanvas
420
391
  preParsedVisualli={resolvedDoc ?? undefined}
421
- isDark={isDark}
392
+ theme={theme}
393
+ comfort={comfort}
394
+ respectForcedColors={respectForcedColors}
395
+ layout={layout}
396
+ controlsPosition={controlsPosition}
397
+ app={app}
398
+ fontBaseUrl={fontBaseUrl}
399
+ loadWebFonts={loadWebFonts}
422
400
  chromaticImmersion={chromaticImmersion}
423
401
  style={{ width: '100%', height: '100%' }}
424
402
  />
@@ -0,0 +1,73 @@
1
+ // ─── Accessible layer ─────────────────────────────────────────────────────────
2
+ //
3
+ // The canvas is a picture, so ideas are mirrored as a visually hidden DOM list:
4
+ // one button per visible idea (label, depth, "has more inside"), reachable with
5
+ // Tab and activated with Enter/Space exactly like a click. Focus is drawn on
6
+ // the canvas (the idea gets the spec's focus outline).
7
+ //
8
+ // Only ideas in view are mirrored (capped), so a 10k-idea map never creates a
9
+ // 10k-element DOM.
10
+
11
+ import React, { memo, useEffect, useMemo, useState } from 'react';
12
+ import { intersectsViewport, nodeBounds, type FlatNode } from '@visualli/core';
13
+ import { useViewportStore } from '../stores/useViewportStore';
14
+
15
+ const MAX_MIRRORED = 150;
16
+
17
+ export interface A11yLayerProps {
18
+ nodes: FlatNode[];
19
+ /** Accessible name of the list, e.g. the layer title. */
20
+ label: string;
21
+ hasChildLayer: (nodeId: string) => boolean;
22
+ onActivate: (node: FlatNode) => void;
23
+ onFocusNode: (nodeId: string | null) => void;
24
+ /** Spoken when the layer changes. */
25
+ announcement?: string;
26
+ }
27
+
28
+ export const A11yLayer = memo(function A11yLayer({ nodes, label, hasChildLayer, onActivate, onFocusNode, announcement }: A11yLayerProps) {
29
+ const cx = useViewportStore((s) => s.centerX);
30
+ const cy = useViewportStore((s) => s.centerY);
31
+ const zoom = useViewportStore((s) => s.zoomLevel);
32
+ const w = useViewportStore((s) => s.canvasWidth);
33
+ const h = useViewportStore((s) => s.canvasHeight);
34
+
35
+ // Debounce: wheel-zoom updates the store on every event.
36
+ const [view, setView] = useState({ cx, cy, zoom, w, h });
37
+ useEffect(() => {
38
+ const t = setTimeout(() => setView({ cx, cy, zoom, w, h }), 120);
39
+ return () => clearTimeout(t);
40
+ }, [cx, cy, zoom, w, h]);
41
+
42
+ const items = useMemo(() => {
43
+ const halfW = view.w / 2 / view.zoom, halfH = view.h / 2 / view.zoom;
44
+ const bounds = { minX: view.cx - halfW, maxX: view.cx + halfW, minY: view.cy - halfH, maxY: view.cy + halfH };
45
+ const visible = nodes.filter((n) => intersectsViewport(n, bounds));
46
+ // Keep the nearest ones when there are too many, then restore reading order (rows, then left to right).
47
+ const picked = visible.length > MAX_MIRRORED
48
+ ? visible.map((n) => ({ n, d: (n.x - view.cx) ** 2 + (n.y - view.cy) ** 2 })).sort((a, b) => a.d - b.d).slice(0, MAX_MIRRORED).map((e) => e.n)
49
+ : visible;
50
+ const row = (n: FlatNode) => Math.round(nodeBounds(n).minY / 150);
51
+ return [...picked].sort((a, b) => row(a) - row(b) || a.x - b.x);
52
+ }, [nodes, view]);
53
+
54
+ return (
55
+ <>
56
+ <div className="vi-sr" role="list" aria-label={label}>
57
+ {items.map((n) => (
58
+ <div role="listitem" key={n.id}>
59
+ <button
60
+ type="button"
61
+ data-node-id={n.id}
62
+ aria-label={`${n.title}. Depth ${n.level + 1}.${hasChildLayer(n.id) ? ' Has more inside; press Enter to step inside.' : ''}`}
63
+ onFocus={() => onFocusNode(n.id)}
64
+ onBlur={() => onFocusNode(null)}
65
+ onClick={() => onActivate(n)}
66
+ />
67
+ </div>
68
+ ))}
69
+ </div>
70
+ <div className="vi-sr" role="status" aria-live="polite">{announcement}</div>
71
+ </>
72
+ );
73
+ });