@lemonriver/core 0.1.0 → 0.2.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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
  3. package/dist/cli/bin.js +4 -4
  4. package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
  5. package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
  6. package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +4 -3
  9. package/dist/locale/index.d.ts +5 -2
  10. package/dist/locale/index.js +2 -2
  11. package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
  12. package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
  13. package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
  14. package/dist/vite/index.d.ts +1 -1
  15. package/dist/vite/index.js +1 -1
  16. package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
  17. package/package.json +21 -15
  18. package/skills/apply-comments/SKILL.md +10 -6
  19. package/skills/create-slide/SKILL.md +2 -0
  20. package/skills/current-slide/SKILL.md +1 -0
  21. package/skills/slide-authoring/SKILL.md +31 -1
  22. package/src/agent/prompt.ts +141 -0
  23. package/src/agent/provider.ts +123 -0
  24. package/src/agent/types.ts +151 -0
  25. package/src/app/app.tsx +2 -0
  26. package/src/app/assets/open-slide.svg +39 -0
  27. package/src/app/components/agent/agent-dock.tsx +35 -0
  28. package/src/app/components/agent/agent-markdown.tsx +45 -0
  29. package/src/app/components/agent/agent-panel.tsx +692 -0
  30. package/src/app/components/agent/agent-presence.tsx +74 -0
  31. package/src/app/components/agent/composer.tsx +120 -0
  32. package/src/app/components/agent/connect-view.tsx +184 -0
  33. package/src/app/components/agent/history-view.tsx +135 -0
  34. package/src/app/components/agent/provider-logo.tsx +34 -0
  35. package/src/app/components/agent/transcript.tsx +609 -0
  36. package/src/app/components/board/board-canvas.tsx +143 -12
  37. package/src/app/components/board/board.css +51 -0
  38. package/src/app/components/board/layout.test.ts +53 -1
  39. package/src/app/components/board/layout.ts +50 -2
  40. package/src/app/components/board/page-node.tsx +287 -13
  41. package/src/app/components/board/use-board-cards.ts +210 -0
  42. package/src/app/components/board/use-page-order.ts +17 -9
  43. package/src/app/components/command/slide-command-menu.tsx +10 -0
  44. package/src/app/components/icon-switcher-indicator.tsx +1 -1
  45. package/src/app/components/inspector/comment-layer.tsx +1011 -0
  46. package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
  47. package/src/app/components/inspector/inspect-overlay.tsx +2 -39
  48. package/src/app/components/inspector/inspector-panel.tsx +10 -93
  49. package/src/app/components/inspector/inspector-provider.tsx +121 -47
  50. package/src/app/components/panel/editor-sidebar.tsx +85 -16
  51. package/src/app/components/panel/panel-shell.tsx +2 -2
  52. package/src/app/components/panel/resize-handle.tsx +129 -0
  53. package/src/app/components/player.tsx +0 -1
  54. package/src/app/components/preview-step-host.tsx +16 -0
  55. package/src/app/components/sidebar/sidebar.tsx +2 -2
  56. package/src/app/components/style-panel/style-panel.tsx +3 -2
  57. package/src/app/components/thumbnail-rail.tsx +76 -1
  58. package/src/app/favicon.ico +0 -0
  59. package/src/app/favicon.svg +39 -0
  60. package/src/app/index.html +2 -1
  61. package/src/app/lib/agent/use-agent.ts +543 -0
  62. package/src/app/lib/inspector/freeze-motion.ts +61 -0
  63. package/src/app/lib/inspector/use-comments.ts +32 -3
  64. package/src/app/lib/locale-store.ts +6 -1
  65. package/src/app/lib/print-ready.ts +9 -6
  66. package/src/app/lib/use-auto-grow.ts +15 -0
  67. package/src/app/lib/utils.ts +4 -0
  68. package/src/app/routes/board.tsx +48 -11
  69. package/src/app/routes/home.tsx +43 -7
  70. package/src/app/routes/presenter.tsx +1 -16
  71. package/src/app/routes/preview.tsx +169 -0
  72. package/src/app/routes/slide.tsx +221 -173
  73. package/src/app/styles.css +234 -80
  74. package/src/editing/placeholder-id.ts +1 -0
  75. package/src/locale/en.ts +127 -5
  76. package/src/locale/es.ts +692 -0
  77. package/src/locale/index.ts +1 -0
  78. package/src/locale/ja.ts +120 -6
  79. package/src/locale/types.ts +132 -5
  80. package/src/locale/zh-cn.ts +117 -5
  81. package/src/locale/zh-tw.ts +117 -5
  82. package/src/app/assets/open-slide.png +0 -0
  83. package/src/app/components/inspector/comment-widget.tsx +0 -119
@@ -2,33 +2,44 @@ import {
2
2
  applyNodeChanges,
3
3
  Background,
4
4
  BackgroundVariant,
5
+ BaseEdge,
5
6
  type Edge,
7
+ EdgeLabelRenderer,
8
+ type EdgeProps,
9
+ type EdgeTypes,
10
+ type InternalNode,
6
11
  MarkerType,
7
12
  MiniMap,
8
13
  type Node,
9
14
  type NodeChange,
10
15
  type NodeProps,
11
16
  type NodeTypes,
17
+ type OnConnectEnd,
12
18
  type OnNodeDrag,
13
19
  Panel,
14
20
  ReactFlow,
15
21
  ReactFlowProvider,
22
+ useInternalNode,
16
23
  useReactFlow,
17
24
  } from '@xyflow/react';
18
- import { Maximize2, Minus, Plus } from 'lucide-react';
25
+ import { Link2, Link2Off, Maximize2, Minus, Plus, X } from 'lucide-react';
19
26
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
20
27
  import { Button } from '@/components/ui/button';
21
28
  import { useLocale } from '@/lib/use-locale';
22
29
  import { cn, pad2 } from '@/lib/utils';
30
+ import type { BoardCard, CardPatch } from '../../../files/board-cards';
23
31
  import { type BoardColumn, columnsAfterMove } from '../../../files/board-structure';
24
32
  import type { DesignSystem } from '../../lib/design';
25
33
  import type { Page } from '../../lib/sdk';
26
34
  import {
27
35
  type BoardLayout,
36
+ type Box,
28
37
  CARD_BOX_H,
29
38
  CARD_BOX_W,
39
+ cardAtPoint,
30
40
  identityOrder,
31
41
  layoutBoard,
42
+ linkCurve,
32
43
  orderAfterMove,
33
44
  slotAtPoint,
34
45
  } from './layout';
@@ -41,6 +52,11 @@ type Props = {
41
52
  pages: Page[];
42
53
  columns: BoardColumn[];
43
54
  notes?: (string | undefined)[];
55
+ cards: (BoardCard | undefined)[];
56
+ cardsReady: boolean;
57
+ /** Linked pages, as deck positions. */
58
+ links: [number, number][];
59
+ showLinks: boolean;
44
60
  design?: DesignSystem;
45
61
  onOpenPage: (index: number) => void;
46
62
  onMovePage: (from: number, to: number) => void;
@@ -49,6 +65,10 @@ type Props = {
49
65
  onDuplicate: (index: number) => void;
50
66
  onDelete: (index: number) => void;
51
67
  onMoveToEnd: (index: number) => void;
68
+ onPatchCard: (index: number, patch: CardPatch) => void;
69
+ onLink: (a: number, b: number) => void;
70
+ onUnlink: (a: number, b: number) => void;
71
+ onToggleLinks: () => void;
52
72
  };
53
73
 
54
74
  type ColumnLabelData = {
@@ -129,6 +149,52 @@ function DropSlot() {
129
149
 
130
150
  const nodeTypes: NodeTypes = { page: PageNode, columnLabel: ColumnLabel, dropSlot: DropSlot };
131
151
 
152
+ type LinkEdgeData = { a: number; b: number; onUnlink: (a: number, b: number) => void };
153
+
154
+ function boxOf(node: InternalNode | undefined): Box | null {
155
+ if (!node) return null;
156
+ const { x, y } = node.internals.positionAbsolute;
157
+ return {
158
+ x,
159
+ y,
160
+ width: node.measured.width ?? CARD_BOX_W,
161
+ height: node.measured.height ?? CARD_BOX_H,
162
+ };
163
+ }
164
+
165
+ /**
166
+ * A loose association between two pages. It is drawn thin and dashed so it can
167
+ * never be read as the deck's order, and it follows the cards as they move.
168
+ */
169
+ function LinkEdge({ id, source, target, data }: EdgeProps) {
170
+ const t = useLocale();
171
+ const { a, b, onUnlink } = data as unknown as LinkEdgeData;
172
+ const from = boxOf(useInternalNode(source));
173
+ const to = boxOf(useInternalNode(target));
174
+ if (!from || !to) return null;
175
+ const curve = linkCurve(from, to);
176
+
177
+ return (
178
+ <>
179
+ <BaseEdge id={id} path={curve.path} className="osd-board-link" interactionWidth={14} />
180
+ <EdgeLabelRenderer>
181
+ <button
182
+ type="button"
183
+ aria-label={t.board.removeLink}
184
+ title={t.board.removeLink}
185
+ onClick={() => onUnlink(a, b)}
186
+ className="osd-board-link-remove nodrag nopan"
187
+ style={{ transform: `translate(-50%, -50%) translate(${curve.x}px, ${curve.y}px)` }}
188
+ >
189
+ <X className="size-3" strokeWidth={2.2} />
190
+ </button>
191
+ </EdgeLabelRenderer>
192
+ </>
193
+ );
194
+ }
195
+
196
+ const edgeTypes: EdgeTypes = { link: LinkEdge };
197
+
132
198
  const edgeStyle = { stroke: 'var(--osd-board-line)', strokeWidth: 1.5 } as const;
133
199
  const marker = {
134
200
  type: MarkerType.ArrowClosed,
@@ -137,6 +203,12 @@ const marker = {
137
203
  color: 'var(--osd-board-line)',
138
204
  };
139
205
 
206
+ const linkDraftStyle = {
207
+ stroke: 'var(--osd-board-link)',
208
+ strokeWidth: 1.5,
209
+ strokeDasharray: '5 4',
210
+ } as const;
211
+
140
212
  const pageIndexOf = (id: string) => Number(id.replace('page-', ''));
141
213
 
142
214
  type BuildArgs = {
@@ -146,8 +218,10 @@ type BuildArgs = {
146
218
  slideId: string;
147
219
  pages: Page[];
148
220
  notes?: (string | undefined)[];
221
+ cards: (BoardCard | undefined)[];
222
+ cardsReady: boolean;
149
223
  design?: DesignSystem;
150
- actions: Pick<PageNodeData, 'onDuplicate' | 'onDelete' | 'onMoveToEnd'>;
224
+ actions: Pick<PageNodeData, 'onDuplicate' | 'onDelete' | 'onMoveToEnd' | 'onPatchCard'>;
151
225
  columnActions: {
152
226
  onRename: (index: number, title: string) => void;
153
227
  onAddPage: (index: number) => void;
@@ -164,6 +238,8 @@ function buildNodes({
164
238
  slideId,
165
239
  pages,
166
240
  notes,
241
+ cards,
242
+ cardsReady,
167
243
  design,
168
244
  actions,
169
245
  columnActions,
@@ -199,7 +275,7 @@ function buildNodes({
199
275
  hidden: true,
200
276
  data: {},
201
277
  };
202
- const cards: Node[] = layout.slots.map((slot) => {
278
+ const pageNodes: Node[] = layout.slots.map((slot) => {
203
279
  const pageIndex = order[slot.index] ?? slot.index;
204
280
  return {
205
281
  id: `page-${pageIndex}`,
@@ -211,6 +287,9 @@ function buildNodes({
211
287
  slideId,
212
288
  page: pages[pageIndex],
213
289
  note: notes?.[pageIndex],
290
+ card: cards[pageIndex],
291
+ cardsReady,
292
+ canLink: cardsReady && pages.length > 1,
214
293
  index: pageIndex,
215
294
  total: pages.length,
216
295
  design,
@@ -219,7 +298,7 @@ function buildNodes({
219
298
  } satisfies PageNodeData as unknown as Record<string, unknown>,
220
299
  };
221
300
  });
222
- return [...labels, drop, ...cards];
301
+ return [...labels, drop, ...pageNodes];
223
302
  }
224
303
 
225
304
  function BoardFlow({
@@ -227,6 +306,10 @@ function BoardFlow({
227
306
  pages,
228
307
  columns,
229
308
  notes,
309
+ cards,
310
+ cardsReady,
311
+ links,
312
+ showLinks,
230
313
  design,
231
314
  onOpenPage,
232
315
  onMovePage,
@@ -235,15 +318,19 @@ function BoardFlow({
235
318
  onDuplicate,
236
319
  onDelete,
237
320
  onMoveToEnd,
321
+ onPatchCard,
322
+ onLink,
323
+ onUnlink,
324
+ onToggleLinks,
238
325
  }: Props) {
239
326
  const t = useLocale();
240
- const { fitView, zoomIn, zoomOut } = useReactFlow();
327
+ const { fitView, zoomIn, zoomOut, screenToFlowPosition } = useReactFlow();
241
328
 
242
329
  const layout = useMemo(() => layoutBoard(columns), [columns]);
243
330
 
244
331
  const actions = useMemo(
245
- () => ({ onDuplicate, onDelete, onMoveToEnd }),
246
- [onDuplicate, onDelete, onMoveToEnd],
332
+ () => ({ onDuplicate, onDelete, onMoveToEnd, onPatchCard }),
333
+ [onDuplicate, onDelete, onMoveToEnd, onPatchCard],
247
334
  );
248
335
 
249
336
  const columnActions = useMemo(
@@ -259,11 +346,13 @@ function BoardFlow({
259
346
  slideId,
260
347
  pages,
261
348
  notes,
349
+ cards,
350
+ cardsReady,
262
351
  design,
263
352
  actions,
264
353
  columnActions,
265
354
  }),
266
- [layout, slideId, pages, notes, design, actions, columnActions],
355
+ [layout, slideId, pages, notes, cards, cardsReady, design, actions, columnActions],
267
356
  );
268
357
 
269
358
  const [nodes, setNodes] = useState<Node[]>(baseNodes);
@@ -299,6 +388,8 @@ function BoardFlow({
299
388
  slideId,
300
389
  pages,
301
390
  notes,
391
+ cards,
392
+ cardsReady,
302
393
  design,
303
394
  actions,
304
395
  columnActions,
@@ -319,7 +410,7 @@ function BoardFlow({
319
410
  });
320
411
  });
321
412
  },
322
- [layout, columns, slideId, pages, notes, design, actions, columnActions],
413
+ [layout, columns, slideId, pages, notes, cards, cardsReady, design, actions, columnActions],
323
414
  );
324
415
 
325
416
  const handleDragStop = useCallback<OnNodeDrag>(
@@ -358,7 +449,7 @@ function BoardFlow({
358
449
 
359
450
  const edges = useMemo<Edge[]>(() => {
360
451
  const byIndex = new Map(layout.slots.map((slot) => [slot.index, slot]));
361
- return pages.slice(0, -1).map((_, i) => {
452
+ const mainLine: Edge[] = pages.slice(0, -1).map((_, i) => {
362
453
  const sameColumn = byIndex.get(i)?.column === byIndex.get(i + 1)?.column;
363
454
  return {
364
455
  id: `line-${i}`,
@@ -373,20 +464,50 @@ function BoardFlow({
373
464
  focusable: false,
374
465
  };
375
466
  });
376
- }, [layout, pages]);
467
+ if (!showLinks) return mainLine;
468
+ const linkLines: Edge[] = links.map(([a, b]) => ({
469
+ id: `link-${Math.min(a, b)}-${Math.max(a, b)}`,
470
+ source: `page-${a}`,
471
+ target: `page-${b}`,
472
+ type: 'link',
473
+ data: { a, b, onUnlink } satisfies LinkEdgeData,
474
+ selectable: false,
475
+ focusable: false,
476
+ }));
477
+ return [...mainLine, ...linkLines];
478
+ }, [layout, pages, links, showLinks, onUnlink]);
479
+
480
+ // A link is drawn by dragging a card's dot onto another card. The whole card is
481
+ // the target rather than a handle on it, because a handle is too small to hit
482
+ // at the zoom a long deck is read at.
483
+ const handleConnectEnd = useCallback<OnConnectEnd>(
484
+ (event, state) => {
485
+ const from = state.fromNode;
486
+ if (from?.type !== 'page') return;
487
+ const point = 'changedTouches' in event ? event.changedTouches[0] : event;
488
+ if (!point) return;
489
+ const at = screenToFlowPosition({ x: point.clientX, y: point.clientY });
490
+ const target = cardAtPoint(layout, at.x, at.y);
491
+ const source = pageIndexOf(from.id);
492
+ if (target !== null && target !== source) onLink(source, target);
493
+ },
494
+ [layout, screenToFlowPosition, onLink],
495
+ );
377
496
 
378
497
  return (
379
498
  <ReactFlow
380
499
  nodes={nodes}
381
500
  edges={edges}
382
501
  nodeTypes={nodeTypes}
502
+ edgeTypes={edgeTypes}
383
503
  onNodesChange={onNodesChange}
384
504
  onNodeDrag={handleDrag}
385
505
  onNodeDragStop={handleDragStop}
386
506
  onNodeDoubleClick={(_, node) => {
387
507
  if (node.type === 'page') onOpenPage(pageIndexOf(node.id));
388
508
  }}
389
- nodesConnectable={false}
509
+ onConnectEnd={handleConnectEnd}
510
+ connectionLineStyle={linkDraftStyle}
390
511
  edgesFocusable={false}
391
512
  onlyRenderVisibleElements
392
513
  onInit={frameStart}
@@ -425,6 +546,16 @@ function BoardFlow({
425
546
  <Button variant="outline" size="icon-sm" aria-label={t.board.fitView} onClick={fitAll}>
426
547
  <Maximize2 />
427
548
  </Button>
549
+ <Button
550
+ variant="outline"
551
+ size="icon-sm"
552
+ aria-label={showLinks ? t.board.hideLinks : t.board.showLinks}
553
+ aria-pressed={showLinks}
554
+ title={showLinks ? t.board.hideLinks : t.board.showLinks}
555
+ onClick={onToggleLinks}
556
+ >
557
+ {showLinks ? <Link2 /> : <Link2Off />}
558
+ </Button>
428
559
  </Panel>
429
560
  </ReactFlow>
430
561
  );
@@ -4,6 +4,7 @@
4
4
  --osd-board-line: color-mix(in oklab, var(--color-muted-foreground) 45%, transparent);
5
5
  --osd-board-mask: color-mix(in oklab, var(--color-background) 82%, transparent);
6
6
  --osd-board-minimap-node: color-mix(in oklab, var(--color-foreground) 28%, transparent);
7
+ --osd-board-link: color-mix(in oklab, var(--color-brand) 55%, transparent);
7
8
  background: var(--color-background);
8
9
  }
9
10
 
@@ -73,3 +74,53 @@
73
74
  .osd-board .react-flow__attribution a {
74
75
  color: color-mix(in oklab, var(--color-muted-foreground) 60%, transparent);
75
76
  }
77
+
78
+ /* Links are loose associations, so they stay thin, dashed and quiet next to the
79
+ solid main line that carries the deck's order. */
80
+ .osd-board .osd-board-link {
81
+ stroke: var(--osd-board-link);
82
+ stroke-width: 1.25;
83
+ stroke-dasharray: 5 4;
84
+ }
85
+
86
+ .osd-board-link-remove {
87
+ position: absolute;
88
+ display: flex;
89
+ align-items: center;
90
+ justify-content: center;
91
+ width: 20px;
92
+ height: 20px;
93
+ border-radius: 999px;
94
+ border: 1px solid var(--color-border);
95
+ background: var(--color-card);
96
+ color: var(--color-muted-foreground);
97
+ pointer-events: all;
98
+ opacity: 0.7;
99
+ transition:
100
+ opacity 120ms ease,
101
+ color 120ms ease;
102
+ }
103
+
104
+ .osd-board-link-remove:hover,
105
+ .osd-board-link-remove:focus-visible {
106
+ opacity: 1;
107
+ color: var(--color-destructive);
108
+ }
109
+
110
+ /* The dot a link is dragged from. It only shows on the card under the pointer,
111
+ so a board at rest carries no extra chrome. */
112
+ .osd-board .osd-board-link-handle {
113
+ width: 14px;
114
+ height: 14px;
115
+ border-radius: 999px;
116
+ border: 2px solid var(--color-card);
117
+ background: var(--color-brand);
118
+ cursor: crosshair;
119
+ opacity: 0;
120
+ transition: opacity 120ms ease;
121
+ }
122
+
123
+ .osd-board .react-flow__node-page:hover .osd-board-link-handle,
124
+ .osd-board .osd-board-link-handle.connectingfrom {
125
+ opacity: 1;
126
+ }
@@ -1,6 +1,17 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { Page } from '../../lib/sdk';
3
- import { CARD_H, CARD_W, layoutBoard, orderAfterMove, seedColumns, slotAtPoint } from './layout';
3
+ import {
4
+ CARD_BOX_H,
5
+ CARD_BOX_W,
6
+ CARD_H,
7
+ CARD_W,
8
+ cardAtPoint,
9
+ layoutBoard,
10
+ linkCurve,
11
+ orderAfterMove,
12
+ seedColumns,
13
+ slotAtPoint,
14
+ } from './layout';
4
15
 
5
16
  /** Pages are identified by their component name, so the fixtures need real names. */
6
17
  function named(name: string): Page {
@@ -107,3 +118,44 @@ describe('orderAfterMove', () => {
107
118
  expect(orderAfterMove(5, 2, 2)).toEqual([0, 1, 2, 3, 4]);
108
119
  });
109
120
  });
121
+
122
+ describe('cardAtPoint', () => {
123
+ const layout = layoutBoard([
124
+ { title: 'Cover', size: 2 },
125
+ { title: 'Chapter One', size: 2 },
126
+ ]);
127
+
128
+ it('finds the card under a point anywhere inside it', () => {
129
+ const slot = layout.slots[3];
130
+ expect(cardAtPoint(layout, slot.x + CARD_BOX_W - 1, slot.y + CARD_BOX_H - 1)).toBe(3);
131
+ });
132
+
133
+ it('reports empty canvas between cards as no card', () => {
134
+ const [a, b] = [layout.slots[0], layout.slots[1]];
135
+ expect(cardAtPoint(layout, a.x + 10, (a.y + CARD_BOX_H + b.y) / 2)).toBeNull();
136
+ expect(cardAtPoint(layout, a.x + CARD_BOX_W + 20, a.y + 10)).toBeNull();
137
+ });
138
+ });
139
+
140
+ describe('linkCurve', () => {
141
+ const box = (x: number, y: number) => ({ x, y, width: 100, height: 60 });
142
+
143
+ it('bows a link between cards in one column out to the right of the column', () => {
144
+ const curve = linkCurve(box(0, 0), box(0, 400));
145
+ expect(curve.x).toBeGreaterThan(100);
146
+ expect(curve.path.startsWith('M 100,18')).toBe(true);
147
+ });
148
+
149
+ it('never ends where the main line does, at the middle of a card side', () => {
150
+ const curve = linkCurve(box(0, 0), box(300, 0));
151
+ expect(curve.path).not.toContain(',30');
152
+ });
153
+
154
+ it('joins cards in different columns between their facing sides, whichever is first', () => {
155
+ const forward = linkCurve(box(0, 0), box(300, 200));
156
+ const backward = linkCurve(box(300, 200), box(0, 0));
157
+ expect(forward.path).toBe(backward.path);
158
+ expect(forward.path.startsWith('M 100,18')).toBe(true);
159
+ expect(forward.path.endsWith('300,218')).toBe(true);
160
+ });
161
+ });
@@ -7,9 +7,11 @@ export const CARD_W = 480;
7
7
  export const CARD_H = (CARD_W * CANVAS_HEIGHT) / CANVAS_WIDTH;
8
8
  /** Height of the card's speaker-note strip. */
9
9
  export const NOTE_H = 52;
10
- /** Card box = preview plus the card's padding, number row and note strip. */
10
+ /** Height of the card's work-memo strip. */
11
+ export const MEMO_H = 40;
12
+ /** Card box = preview plus the card's padding, title row, note strip and memo strip. */
11
13
  export const CARD_BOX_W = CARD_W + 12;
12
- export const CARD_BOX_H = CARD_H + 36 + NOTE_H;
14
+ export const CARD_BOX_H = CARD_H + 60 + NOTE_H + MEMO_H;
13
15
  /** Gap between columns, and between cards inside a column. */
14
16
  const COL_GAP = 88;
15
17
  const ROW_GAP = 52;
@@ -122,6 +124,52 @@ export function slotAtPoint(layout: BoardLayout, x: number, y: number, dragged:
122
124
  return best;
123
125
  }
124
126
 
127
+ /** Deck position of the card under a point, or null over empty canvas. */
128
+ export function cardAtPoint(layout: BoardLayout, x: number, y: number): number | null {
129
+ for (const slot of layout.slots) {
130
+ if (x >= slot.x && x <= slot.x + CARD_BOX_W && y >= slot.y && y <= slot.y + CARD_BOX_H) {
131
+ return slot.index;
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ export type Box = { x: number; y: number; width: number; height: number };
138
+
139
+ const LINK_ANCHOR = 0.3;
140
+
141
+ /**
142
+ * A link's curve and the point its remove button sits on. Cards in one column
143
+ * are joined by an arc bowing into the gap on their right, which the main line
144
+ * never uses; cards in different columns by a curve between their facing sides.
145
+ */
146
+ export function linkCurve(a: Box, b: Box): { path: string; x: number; y: number } {
147
+ // The main line enters and leaves a card at its vertical middle; links attach
148
+ // higher up so the two never share an end.
149
+ const ay = a.y + a.height * LINK_ANCHOR;
150
+ const by = b.y + b.height * LINK_ANCHOR;
151
+ if (Math.abs(a.x - b.x) < a.width / 2) {
152
+ const sx = a.x + a.width;
153
+ const tx = b.x + b.width;
154
+ // Stays inside the gap between columns, so the arc never slips behind the next column.
155
+ const bow = 56 + Math.min(Math.abs(by - ay) / 8, 36);
156
+ return {
157
+ path: `M ${sx},${ay} C ${sx + bow},${ay} ${tx + bow},${by} ${tx},${by}`,
158
+ x: (sx + tx) / 2 + 0.75 * bow,
159
+ y: (ay + by) / 2,
160
+ };
161
+ }
162
+ const [left, right, ly, ry] = a.x < b.x ? [a, b, ay, by] : [b, a, by, ay];
163
+ const sx = left.x + left.width;
164
+ const tx = right.x;
165
+ const reach = Math.max((tx - sx) / 2, 40);
166
+ return {
167
+ path: `M ${sx},${ly} C ${sx + reach},${ly} ${tx - reach},${ry} ${tx},${ry}`,
168
+ x: (sx + tx) / 2,
169
+ y: (ly + ry) / 2,
170
+ };
171
+ }
172
+
125
173
  export const identityOrder = (total: number) => Array.from({ length: total }, (_, i) => i);
126
174
 
127
175
  /** The deck's order once the card at `from` lands on `to`, as original page indexes. */