@openprd/cli 0.1.18 → 0.1.21

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 (118) hide show
  1. package/.openprd/changes/openprd-control-plane-v020/.openprd.yaml +2 -0
  2. package/.openprd/changes/openprd-control-plane-v020/design.md +78 -0
  3. package/.openprd/changes/openprd-control-plane-v020/proposal.md +54 -0
  4. package/.openprd/changes/openprd-control-plane-v020/specs/agent-requirements/spec.md +16 -0
  5. package/.openprd/changes/openprd-control-plane-v020/task-events.jsonl +27 -0
  6. package/.openprd/changes/openprd-control-plane-v020/tasks-002.md +35 -0
  7. package/.openprd/changes/openprd-control-plane-v020/tasks.md +427 -0
  8. package/.openprd/changes/openprd-generated-change/.openprd.yaml +2 -0
  9. package/.openprd/changes/openprd-generated-change/design.md +50 -0
  10. package/.openprd/changes/openprd-generated-change/proposal.md +37 -0
  11. package/.openprd/changes/openprd-generated-change/specs/agent-requirements/spec.md +16 -0
  12. package/.openprd/changes/openprd-generated-change/task-events.jsonl +21 -0
  13. package/.openprd/changes/openprd-generated-change/tasks.md +357 -0
  14. package/.openprd/changes/remove-session-tracking-humane-approval/.openprd.yaml +2 -0
  15. package/.openprd/changes/remove-session-tracking-humane-approval/design.md +52 -0
  16. package/.openprd/changes/remove-session-tracking-humane-approval/proposal.md +35 -0
  17. package/.openprd/changes/remove-session-tracking-humane-approval/specs/agent-requirements/spec.md +16 -0
  18. package/.openprd/changes/remove-session-tracking-humane-approval/task-events.jsonl +1 -0
  19. package/.openprd/changes/remove-session-tracking-humane-approval/tasks.md +170 -0
  20. package/.openprd/design/active/asset-spec.md +19 -14
  21. package/.openprd/design/active/direction-plan.md +19 -3
  22. package/.openprd/design/active/facts-sheet.md +16 -7
  23. package/.openprd/design/active/image-preflight.md +6 -5
  24. package/.openprd/design/active/review-studio-v020-directions/compare-plan.json +34 -0
  25. package/.openprd/design/active/review-studio-v020-directions/contact-sheet.jpg +0 -0
  26. package/.openprd/design/active/review-studio-v020-directions/crops/01.png +0 -0
  27. package/.openprd/design/active/review-studio-v020-directions/crops/02.png +0 -0
  28. package/.openprd/design/active/review-studio-v020-directions/crops/03.png +0 -0
  29. package/.openprd/design/active/review-studio-v020-directions/focus-board.template.json +69 -0
  30. package/.openprd/design/active/review-studio-v020-directions/parallel-board.template.json +45 -0
  31. package/.openprd/design/active/review-studio-v020-directions/reference-set.json +143 -0
  32. package/.openprd/design/active/review-studio-v020-directions/source.png +0 -0
  33. package/.openprd/design/active/selected-direction.md +23 -9
  34. package/.openprd/engagements/active/control-plane-architecture.json +203 -0
  35. package/.openprd/engagements/active/control-plane-intake.json +418 -0
  36. package/.openprd/engagements/active/prd.md +186 -100
  37. package/.openprd/engagements/active/review-presentation-v0018.json +176 -0
  38. package/.openprd/i18n-config.json +12 -0
  39. package/.openprd/ledger/events.jsonl +6 -0
  40. package/.openprd/ledger/heads/ebea1a71a9daa566f1c91b53.json +10 -0
  41. package/.openprd/manifest.json +21 -0
  42. package/AGENTS.md +1 -0
  43. package/README.md +22 -0
  44. package/README_EN.md +27 -0
  45. package/package.json +1 -1
  46. package/skills/openprd-frontend-design/SKILL.md +16 -0
  47. package/skills/openprd-harness/SKILL.md +1 -1
  48. package/skills/openprd-quality/SKILL.md +1 -1
  49. package/skills/openprd-requirement-intake/SKILL.md +1 -1
  50. package/skills/openprd-shared/SKILL.md +1 -1
  51. package/src/adapters/adapter-spi.js +193 -0
  52. package/src/adapters/capability-envelope.js +98 -0
  53. package/src/adapters/event-normalizer.js +55 -0
  54. package/src/adapters/index.js +4 -0
  55. package/src/adapters/install-safety.js +249 -0
  56. package/src/agent-canonical-content.js +6 -2
  57. package/src/agent-integration.js +147 -42
  58. package/src/canvas-app-client-app.js +838 -0
  59. package/src/canvas-app-client-helpers.js +1138 -0
  60. package/src/canvas-app-styles.js +898 -0
  61. package/src/canvas-app.html.js +6 -2781
  62. package/src/canvas-binding.js +414 -0
  63. package/src/canvas-codex-relay.js +430 -0
  64. package/src/canvas-i18n.js +8 -0
  65. package/src/canvas-scene.js +377 -0
  66. package/src/canvas-session-store.js +148 -0
  67. package/src/canvas-workspace.js +59 -1066
  68. package/src/cli/args.js +63 -4
  69. package/src/cli/gate-print.js +17 -0
  70. package/src/cli/quality-commands.js +18 -0
  71. package/src/cli/quality-print.js +10 -0
  72. package/src/cli/runtime-print.js +24 -0
  73. package/src/codex-hook-runner-template.mjs +87 -77
  74. package/src/codex-runtime.js +48 -5
  75. package/src/context/cache.js +245 -0
  76. package/src/context/compiler.js +438 -0
  77. package/src/context/constants.js +30 -0
  78. package/src/context/index.js +39 -0
  79. package/src/context/redaction.js +84 -0
  80. package/src/context/stable.js +69 -0
  81. package/src/context/telemetry.js +42 -0
  82. package/src/dev-standards.js +57 -0
  83. package/src/fleet.js +112 -95
  84. package/src/gates/index.js +2 -0
  85. package/src/gates/scoped-gates.js +256 -0
  86. package/src/gates/store.js +126 -0
  87. package/src/gates/workspace.js +41 -0
  88. package/src/html-artifacts.js +725 -28
  89. package/src/kernel/atomic-store.js +299 -0
  90. package/src/kernel/event-envelope.js +166 -0
  91. package/src/kernel/index.js +4 -0
  92. package/src/kernel/project-ledger.js +467 -0
  93. package/src/kernel/project-manifest.js +205 -0
  94. package/src/knowledge-v3/index.js +1 -0
  95. package/src/knowledge-v3/lifecycle.js +290 -0
  96. package/src/knowledge.js +14 -7
  97. package/src/openprd.js +71 -2
  98. package/src/review-model.js +413 -0
  99. package/src/review-presentation.js +1 -1
  100. package/src/run-harness.js +431 -37
  101. package/src/runtime/cli_runtime_README.md +28 -0
  102. package/src/runtime/errors.js +66 -0
  103. package/src/runtime/index.js +44 -0
  104. package/src/runtime/lane-schema.js +141 -0
  105. package/src/runtime/lane-store.js +279 -0
  106. package/src/runtime/task-runtime.js +449 -0
  107. package/src/runtime/workspace.js +179 -0
  108. package/src/runtime/write-set.js +206 -0
  109. package/src/session-binding.js +16 -3
  110. package/src/session-registry.js +59 -1
  111. package/src/upgrade/fleet-mutation.js +166 -0
  112. package/src/upgrade/fleet-transaction.js +398 -0
  113. package/src/upgrade/transaction-store.js +416 -0
  114. package/src/visual-compare-core.js +66 -27
  115. package/src/visual-compare.js +18 -12
  116. package/src/workspace-core.js +109 -7
  117. package/src/workspace-registry.js +39 -1
  118. package/src/workspace-workflow.js +18 -15
@@ -22,6 +22,9 @@ import {
22
22
  CANVAS_LOCALE_STORAGE_KEY,
23
23
  CANVAS_LOCALES,
24
24
  } from './canvas-i18n.js';
25
+ import { CANVAS_APP_STYLES } from './canvas-app-styles.js';
26
+ import { CANVAS_APP_CLIENT_HELPERS } from './canvas-app-client-helpers.js';
27
+ import { CANVAS_APP_CLIENT_APP } from './canvas-app-client-app.js';
25
28
 
26
29
  function escapeHtml(value) {
27
30
  return String(value ?? '')
@@ -67,894 +70,7 @@ function renderCanvasAppHtml(session) {
67
70
  </script>
68
71
  <link rel="stylesheet" href="https://esm.sh/@excalidraw/excalidraw@0.18.0/dist/dev/index.css" />
69
72
  <style>
70
- :root {
71
- color-scheme: light;
72
- --paper: #ffffff;
73
- --ink: #111827;
74
- --muted: #64748b;
75
- --line: #e5e7eb;
76
- --rail: #ffffff;
77
- --accent: #2563eb;
78
- --accent-hover: #1d4ed8;
79
- --accent-soft: #eff6ff;
80
- --warn: #b45309;
81
- }
82
-
83
- * {
84
- box-sizing: border-box;
85
- }
86
-
87
- html,
88
- body,
89
- #root {
90
- width: 100%;
91
- height: 100%;
92
- margin: 0;
93
- }
94
-
95
- body {
96
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
97
- background: var(--paper);
98
- color: var(--ink);
99
- letter-spacing: 0;
100
- overflow: hidden;
101
- }
102
-
103
- button,
104
- input,
105
- select,
106
- textarea {
107
- font: inherit;
108
- }
109
-
110
- .shell {
111
- display: grid;
112
- grid-template-rows: 48px minmax(0, 1fr);
113
- width: 100%;
114
- height: 100%;
115
- background: var(--paper);
116
- }
117
-
118
- .topbar {
119
- display: flex;
120
- align-items: center;
121
- gap: 12px;
122
- min-width: 0;
123
- padding: 0 16px;
124
- border-bottom: 1px solid var(--line);
125
- background: rgba(255, 255, 255, 0.96);
126
- }
127
-
128
- .brand {
129
- display: flex;
130
- align-items: baseline;
131
- gap: 8px;
132
- min-width: 220px;
133
- white-space: nowrap;
134
- }
135
-
136
- .brand strong {
137
- font-size: 14px;
138
- line-height: 1;
139
- }
140
-
141
- .brand span {
142
- color: var(--muted);
143
- font-size: 12px;
144
- }
145
-
146
- .chipbar {
147
- display: flex;
148
- flex-wrap: nowrap;
149
- gap: 8px;
150
- min-width: 0;
151
- overflow: hidden;
152
- }
153
-
154
- .chip {
155
- display: inline-flex;
156
- align-items: center;
157
- min-width: 0;
158
- height: 26px;
159
- max-width: min(30vw, 360px);
160
- padding: 0 9px;
161
- border: 1px solid var(--line);
162
- border-radius: 6px;
163
- color: var(--muted);
164
- background: #ffffff;
165
- font-size: 12px;
166
- white-space: nowrap;
167
- }
168
-
169
- .chip span {
170
- overflow: hidden;
171
- text-overflow: ellipsis;
172
- }
173
-
174
- .actions {
175
- display: flex;
176
- align-items: center;
177
- gap: 8px;
178
- margin-left: auto;
179
- white-space: nowrap;
180
- }
181
-
182
- .icon-button,
183
- .text-button {
184
- display: inline-flex;
185
- align-items: center;
186
- justify-content: center;
187
- height: 32px;
188
- border: 1px solid var(--line);
189
- border-radius: 6px;
190
- color: var(--ink);
191
- background: #ffffff;
192
- cursor: pointer;
193
- }
194
-
195
- .icon-button {
196
- width: 34px;
197
- }
198
-
199
- .text-button {
200
- gap: 7px;
201
- padding: 0 10px;
202
- }
203
-
204
- .icon-button:hover,
205
- .text-button:hover {
206
- border-color: #bfdbfe;
207
- background: var(--accent-soft);
208
- }
209
-
210
- .primary-button {
211
- border-color: var(--accent);
212
- color: #ffffff;
213
- background: var(--accent);
214
- }
215
-
216
- .primary-button:hover {
217
- border-color: var(--accent-hover);
218
- background: var(--accent-hover);
219
- }
220
-
221
- .subtle-button {
222
- border-color: #e2e8f0;
223
- color: #334155;
224
- background: #f1f5f9;
225
- }
226
-
227
- .subtle-button:hover {
228
- border-color: #cbd5e1;
229
- color: #1f2937;
230
- background: #e5e7eb;
231
- }
232
-
233
- .text-button:disabled {
234
- cursor: default;
235
- }
236
-
237
- .primary-button:disabled {
238
- opacity: 1;
239
- border-color: var(--accent);
240
- color: #ffffff;
241
- background: var(--accent);
242
- box-shadow: none;
243
- }
244
-
245
- .text-button:disabled:not(.primary-button) {
246
- opacity: 0.6;
247
- border-color: var(--line);
248
- color: #9ca3af;
249
- background: #f8fafc;
250
- }
251
-
252
- .language-select {
253
- height: 32px;
254
- min-width: 84px;
255
- padding: 0 8px;
256
- border: 1px solid var(--line);
257
- border-radius: 6px;
258
- color: var(--ink);
259
- background: #ffffff;
260
- cursor: pointer;
261
- }
262
-
263
- .language-select:focus,
264
- .language-select:focus-visible {
265
- outline: none;
266
- border-color: #cbd5e1;
267
- box-shadow: none;
268
- }
269
-
270
- .layout {
271
- display: grid;
272
- grid-template-columns: minmax(0, 1fr) 232px;
273
- min-height: 0;
274
- }
275
-
276
- .rail {
277
- min-width: 0;
278
- border-left: 1px solid var(--line);
279
- background: var(--rail);
280
- padding: 18px 16px;
281
- overflow: auto;
282
- }
283
-
284
- .rail-section {
285
- margin-bottom: 18px;
286
- }
287
-
288
- .rail-title {
289
- margin: 0 0 8px;
290
- color: var(--muted);
291
- font-size: 11px;
292
- font-weight: 700;
293
- letter-spacing: 0.08em;
294
- text-transform: uppercase;
295
- }
296
-
297
- .rail-value {
298
- margin: 0;
299
- color: var(--ink);
300
- font-size: 13px;
301
- line-height: 1.45;
302
- overflow-wrap: anywhere;
303
- }
304
-
305
- .thread-summary {
306
- display: grid;
307
- gap: 6px;
308
- padding: 0;
309
- }
310
-
311
- .thread-title {
312
- margin: 0;
313
- color: var(--ink);
314
- font-size: 14px;
315
- font-weight: 700;
316
- line-height: 1.35;
317
- overflow-wrap: anywhere;
318
- }
319
-
320
- .thread-id {
321
- margin: 0;
322
- color: var(--muted);
323
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
324
- font-size: 11px;
325
- line-height: 1.45;
326
- overflow-wrap: anywhere;
327
- }
328
-
329
- .thread-id-row {
330
- display: grid;
331
- grid-template-columns: minmax(0, 1fr) 28px;
332
- align-items: start;
333
- gap: 6px;
334
- }
335
-
336
- .thread-copy-button {
337
- display: inline-flex;
338
- align-items: center;
339
- justify-content: center;
340
- width: 28px;
341
- height: 28px;
342
- border: 1px solid #dbeafe;
343
- border-radius: 6px;
344
- color: var(--accent);
345
- background: var(--accent-soft);
346
- cursor: pointer;
347
- }
348
-
349
- .thread-copy-button svg {
350
- width: 15px;
351
- height: 15px;
352
- }
353
-
354
- .thread-copy-button:hover {
355
- border-color: #bfdbfe;
356
- background: #dbeafe;
357
- }
358
-
359
- .thread-copy-button:focus,
360
- .thread-copy-button:focus-visible {
361
- outline: none;
362
- border-color: var(--accent);
363
- box-shadow: 0 0 0 2px #dbeafe;
364
- }
365
-
366
- .rail-actions {
367
- display: grid;
368
- gap: 8px;
369
- margin-top: 20px;
370
- padding-top: 16px;
371
- border-top: 1px solid var(--line);
372
- }
373
-
374
- .rail-heading {
375
- margin: 0 0 2px;
376
- color: var(--muted);
377
- font-size: 11px;
378
- font-weight: 700;
379
- letter-spacing: 0.08em;
380
- text-transform: uppercase;
381
- }
382
-
383
- .rail-action-group {
384
- display: grid;
385
- gap: 7px;
386
- margin-bottom: 10px;
387
- }
388
-
389
- .guide-strip {
390
- display: grid;
391
- gap: 4px;
392
- padding: 9px 11px;
393
- border: 1px solid #c7d2fe;
394
- border-radius: 8px;
395
- background: #eef2ff;
396
- }
397
-
398
- .guide-strip strong {
399
- color: #3730a3;
400
- font-size: 12px;
401
- line-height: 1.4;
402
- }
403
-
404
- .guide-strip span {
405
- color: #52525b;
406
- font-size: 11px;
407
- line-height: 1.45;
408
- }
409
-
410
- .rail-action-grid {
411
- display: grid;
412
- grid-template-columns: repeat(3, minmax(0, 1fr));
413
- gap: 6px;
414
- }
415
-
416
- .rail-action-button {
417
- display: grid;
418
- grid-template-rows: auto auto;
419
- place-items: center;
420
- gap: 5px;
421
- width: 100%;
422
- min-height: 58px;
423
- padding: 7px 4px;
424
- border: 1px solid var(--line);
425
- border-radius: 6px;
426
- color: var(--ink);
427
- background: #ffffff;
428
- font-size: 12px;
429
- line-height: 1.15;
430
- cursor: pointer;
431
- text-align: center;
432
- }
433
-
434
- .rail-action-button svg {
435
- width: 18px;
436
- height: 18px;
437
- }
438
-
439
- .rail-action-button span {
440
- display: block;
441
- max-width: 100%;
442
- overflow: hidden;
443
- text-overflow: ellipsis;
444
- white-space: nowrap;
445
- }
446
-
447
- .rail-action-button:hover {
448
- border-color: #bfdbfe;
449
- background: var(--accent-soft);
450
- }
451
-
452
- .rail-action-button.primary {
453
- border-color: var(--accent);
454
- color: #ffffff;
455
- background: var(--accent);
456
- }
457
-
458
- .rail-action-button.primary:hover {
459
- border-color: var(--accent-hover);
460
- background: var(--accent-hover);
461
- }
462
-
463
- .rail-action-button.danger {
464
- border-color: #fecaca;
465
- color: #b91c1c;
466
- }
467
-
468
- .rail-action-button.danger:hover {
469
- border-color: #fca5a5;
470
- background: #fef2f2;
471
- }
472
-
473
- .rail-action-button:disabled {
474
- cursor: default;
475
- }
476
-
477
- .rail-action-button:disabled:not(.primary) {
478
- opacity: 0.6;
479
- color: #9ca3af;
480
- background: #f8fafc;
481
- }
482
-
483
- .rail-action-button.primary:disabled {
484
- opacity: 1;
485
- border-color: var(--accent);
486
- color: #ffffff;
487
- background: var(--accent);
488
- }
489
-
490
- .action-status {
491
- display: grid;
492
- gap: 8px;
493
- margin: 2px 0 8px;
494
- padding: 8px 9px;
495
- border: 1px solid #bbf7d0;
496
- border-radius: 6px;
497
- color: #333333;
498
- background: #ecfdf5;
499
- font-size: 12px;
500
- line-height: 1.4;
501
- overflow-wrap: anywhere;
502
- }
503
-
504
- .action-status.busy {
505
- border-color: #bfdbfe;
506
- color: #1e3a8a;
507
- background: #eff6ff;
508
- }
509
-
510
- .action-status.error {
511
- border-color: #fecaca;
512
- color: #991b1b;
513
- background: #fef2f2;
514
- }
515
-
516
- .status-folder-button {
517
- justify-self: start;
518
- min-height: 28px;
519
- padding: 5px 9px;
520
- border: 1px solid var(--accent);
521
- border-radius: 6px;
522
- color: #ffffff;
523
- background: var(--accent);
524
- font-size: 12px;
525
- font-weight: 600;
526
- cursor: pointer;
527
- }
528
-
529
- .status-folder-button:hover {
530
- border-color: var(--accent-hover);
531
- background: var(--accent-hover);
532
- }
533
-
534
- .status-folder-button:disabled {
535
- cursor: default;
536
- opacity: 0.75;
537
- }
538
-
539
- .library-grid {
540
- display: grid;
541
- grid-template-columns: repeat(2, minmax(0, 1fr));
542
- gap: 8px;
543
- }
544
-
545
- .library-tile {
546
- display: grid;
547
- gap: 6px;
548
- min-width: 0;
549
- padding: 8px;
550
- border: 1px solid var(--line);
551
- border-radius: 6px;
552
- color: var(--ink);
553
- background: #ffffff;
554
- cursor: pointer;
555
- text-align: left;
556
- }
557
-
558
- .library-tile:hover {
559
- border-color: #bfdbfe;
560
- background: var(--accent-soft);
561
- }
562
-
563
- .library-card-preview {
564
- position: relative;
565
- width: 100%;
566
- aspect-ratio: 4 / 3;
567
- border: 1px solid #94a3b8;
568
- border-radius: 5px;
569
- background: #ffffff;
570
- }
571
-
572
- .library-card-preview::after {
573
- content: "";
574
- position: absolute;
575
- inset: 38% 18%;
576
- border-top: 2px solid #cbd5e1;
577
- border-bottom: 2px solid #e2e8f0;
578
- }
579
-
580
- .library-item-preview {
581
- display: grid;
582
- place-items: center;
583
- width: 100%;
584
- aspect-ratio: 4 / 3;
585
- overflow: hidden;
586
- border: 1px solid #cbd5e1;
587
- border-radius: 5px;
588
- background: #ffffff;
589
- }
590
-
591
- .library-item-preview img {
592
- width: 100%;
593
- height: 100%;
594
- object-fit: contain;
595
- }
596
-
597
- .library-item-preview span {
598
- color: var(--muted);
599
- font-size: 11px;
600
- font-weight: 600;
601
- }
602
-
603
- .library-tile span {
604
- color: var(--ink);
605
- font-size: 12px;
606
- font-weight: 600;
607
- line-height: 1.25;
608
- overflow-wrap: anywhere;
609
- }
610
-
611
- .template-grid {
612
- display: grid;
613
- grid-template-columns: repeat(2, minmax(0, 1fr));
614
- gap: 8px;
615
- }
616
-
617
- .template-tile {
618
- display: grid;
619
- gap: 6px;
620
- min-width: 0;
621
- padding: 8px;
622
- border: 1px solid var(--line);
623
- border-radius: 6px;
624
- color: var(--ink);
625
- background: #ffffff;
626
- cursor: pointer;
627
- text-align: left;
628
- }
629
-
630
- .template-tile:hover {
631
- border-color: #bfdbfe;
632
- background: var(--accent-soft);
633
- }
634
-
635
- .template-preview {
636
- position: relative;
637
- display: grid;
638
- place-items: center;
639
- width: 100%;
640
- min-height: 42px;
641
- max-height: 86px;
642
- overflow: hidden;
643
- border: 1px dashed #93c5fd;
644
- border-radius: 5px;
645
- background: #eff6ff;
646
- }
647
-
648
- .template-preview::before {
649
- content: "AI";
650
- display: inline-grid;
651
- place-items: center;
652
- width: 30px;
653
- height: 20px;
654
- border-radius: 5px;
655
- color: #ffffff;
656
- background: var(--accent);
657
- font-size: 11px;
658
- font-weight: 800;
659
- letter-spacing: 0;
660
- }
661
-
662
- .template-tile strong,
663
- .template-tile span {
664
- color: var(--ink);
665
- font-size: 12px;
666
- line-height: 1.25;
667
- overflow-wrap: anywhere;
668
- }
669
-
670
- .template-tile strong {
671
- font-weight: 700;
672
- }
673
-
674
- .template-tile span {
675
- color: var(--muted);
676
- font-weight: 600;
677
- }
678
-
679
- .template-compose-preview {
680
- display: grid;
681
- place-items: center;
682
- padding: 14px;
683
- border: 1px solid var(--line);
684
- border-radius: 6px;
685
- background: #f8fafc;
686
- }
687
-
688
- .template-compose-frame {
689
- position: relative;
690
- display: grid;
691
- place-items: center;
692
- width: min(100%, 320px);
693
- max-height: 240px;
694
- border: 1px dashed #93c5fd;
695
- border-radius: 6px;
696
- background: #eff6ff;
697
- color: #1e3a8a;
698
- font-size: 13px;
699
- font-weight: 700;
700
- }
701
-
702
- .template-compose-frame::before {
703
- content: "AI";
704
- position: absolute;
705
- top: 10px;
706
- left: 10px;
707
- display: inline-grid;
708
- place-items: center;
709
- width: 34px;
710
- height: 22px;
711
- border-radius: 5px;
712
- color: #ffffff;
713
- background: var(--accent);
714
- font-size: 12px;
715
- font-weight: 800;
716
- letter-spacing: 0;
717
- }
718
-
719
- .rail-note {
720
- margin: 6px 0 0;
721
- color: var(--muted);
722
- font-size: 12px;
723
- line-height: 1.45;
724
- }
725
-
726
- .path {
727
- padding: 8px;
728
- border: 1px solid var(--line);
729
- border-radius: 6px;
730
- background: #ffffff;
731
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
732
- font-size: 11px;
733
- line-height: 1.4;
734
- overflow-wrap: anywhere;
735
- }
736
-
737
- .canvas-wrap {
738
- position: relative;
739
- min-width: 0;
740
- min-height: 0;
741
- background: #f8fafc;
742
- }
743
-
744
- .canvas-wrap .excalidraw {
745
- --color-primary: #2563eb;
746
- --color-primary-darker: #1d4ed8;
747
- --color-primary-light: #eff6ff;
748
- --border-radius-md: 6px;
749
- --border-radius-lg: 8px;
750
- height: 100%;
751
- }
752
-
753
- .canvas-wrap .excalidraw [data-testid="main-menu-trigger"],
754
- .canvas-wrap .excalidraw .main-menu-trigger,
755
- .canvas-wrap .excalidraw [data-testid="library-button"],
756
- .canvas-wrap .excalidraw .library-button,
757
- .canvas-wrap .excalidraw .default-sidebar-trigger,
758
- .canvas-wrap .excalidraw .sidebar-trigger__label-element,
759
- .canvas-wrap .excalidraw button[aria-label="Library"],
760
- .canvas-wrap .excalidraw button[aria-label="素材库"],
761
- .canvas-wrap .excalidraw button[title="Library"],
762
- .canvas-wrap .excalidraw button[title="素材库"] {
763
- display: none !important;
764
- }
765
-
766
- .fallback {
767
- display: grid;
768
- place-items: center;
769
- height: 100%;
770
- padding: 32px;
771
- color: var(--muted);
772
- text-align: center;
773
- }
774
-
775
- .fallback strong {
776
- display: block;
777
- margin-bottom: 8px;
778
- color: var(--ink);
779
- }
780
-
781
- .fallback pre {
782
- max-width: min(760px, calc(100vw - 48px));
783
- max-height: 220px;
784
- margin: 14px 0 0;
785
- padding: 10px 12px;
786
- overflow: auto;
787
- border: 1px solid var(--line);
788
- border-radius: 6px;
789
- background: #ffffff;
790
- color: var(--warn);
791
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
792
- font-size: 11px;
793
- line-height: 1.5;
794
- text-align: left;
795
- white-space: pre-wrap;
796
- }
797
-
798
- .selection-sendbar {
799
- position: absolute;
800
- z-index: 20;
801
- left: 50%;
802
- bottom: 20px;
803
- display: flex;
804
- align-items: center;
805
- gap: 12px;
806
- max-width: calc(100% - 40px);
807
- padding: 8px;
808
- border: 1px solid var(--line);
809
- border-radius: 8px;
810
- background: rgba(255, 255, 255, 0.98);
811
- box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
812
- transform: translateX(-50%);
813
- }
814
-
815
- .selection-sendbar strong {
816
- font-size: 12px;
817
- white-space: nowrap;
818
- }
819
-
820
- .selection-sendbar .text-button {
821
- min-width: 118px;
822
- flex: 0 0 auto;
823
- white-space: nowrap;
824
- }
825
-
826
- .selection-sendbar .primary-button {
827
- min-width: 136px;
828
- }
829
-
830
- .selection-sendbar .text-button span {
831
- white-space: nowrap;
832
- }
833
-
834
- .composer-backdrop {
835
- position: absolute;
836
- inset: 0;
837
- z-index: 30;
838
- display: grid;
839
- place-items: center;
840
- padding: 24px;
841
- background: rgba(15, 23, 42, 0.28);
842
- }
843
-
844
- .composer-modal {
845
- width: min(560px, calc(100vw - 48px));
846
- max-height: calc(100vh - 96px);
847
- overflow: auto;
848
- border: 1px solid var(--line);
849
- border-radius: 8px;
850
- background: #ffffff;
851
- box-shadow: 0 20px 48px rgba(15, 23, 42, 0.22);
852
- }
853
-
854
- .composer-header,
855
- .composer-body,
856
- .composer-actions {
857
- padding: 16px;
858
- }
859
-
860
- .composer-header {
861
- display: flex;
862
- align-items: center;
863
- justify-content: space-between;
864
- gap: 12px;
865
- border-bottom: 1px solid var(--line);
866
- }
867
-
868
- .composer-header h2 {
869
- margin: 0;
870
- color: var(--ink);
871
- font-size: 16px;
872
- line-height: 1.3;
873
- }
874
-
875
- .composer-body {
876
- display: grid;
877
- gap: 12px;
878
- }
879
-
880
- .composer-label {
881
- margin: 0;
882
- color: var(--muted);
883
- font-size: 12px;
884
- font-weight: 700;
885
- line-height: 1.35;
886
- }
887
-
888
- .composer-preview {
889
- width: 100%;
890
- min-height: 180px;
891
- max-height: 340px;
892
- border: 1px solid var(--line);
893
- border-radius: 6px;
894
- background: #f8fafc;
895
- object-fit: contain;
896
- }
897
-
898
- .composer-input {
899
- width: 100%;
900
- min-height: 110px;
901
- padding: 10px 12px;
902
- border: 1px solid var(--line);
903
- border-radius: 6px;
904
- color: var(--ink);
905
- background: #ffffff;
906
- font: inherit;
907
- line-height: 1.5;
908
- resize: vertical;
909
- }
910
-
911
- .composer-input:focus {
912
- outline: 2px solid #bfdbfe;
913
- border-color: var(--accent);
914
- }
915
-
916
- .composer-status {
917
- margin: 0;
918
- color: var(--muted);
919
- font-size: 12px;
920
- line-height: 1.45;
921
- }
922
-
923
- .composer-error {
924
- margin: 0;
925
- color: #b91c1c;
926
- font-size: 12px;
927
- line-height: 1.45;
928
- }
929
-
930
- .composer-actions {
931
- display: flex;
932
- justify-content: flex-end;
933
- gap: 8px;
934
- border-top: 1px solid var(--line);
935
- }
936
-
937
- @media (max-width: 860px) {
938
- .layout {
939
- grid-template-columns: 1fr;
940
- }
941
-
942
- .rail {
943
- display: none;
944
- }
945
-
946
- .brand {
947
- min-width: 150px;
948
- }
949
-
950
- .chipbar {
951
- display: none;
952
- }
953
-
954
- .text-button span {
955
- display: none;
956
- }
957
- }
73
+ ${CANVAS_APP_STYLES}
958
74
  </style>
959
75
  </head>
960
76
  <body>
@@ -1009,1899 +125,8 @@ function renderCanvasAppHtml(session) {
1009
125
  });
1010
126
  </script>
1011
127
  <script type="module">
1012
- import React from "react";
1013
- import { createRoot } from "react-dom/client";
1014
- import { Excalidraw, convertToExcalidrawElements, exportToCanvas, serializeAsJSON } from "@excalidraw/excalidraw";
1015
-
1016
- const session = JSON.parse(document.getElementById("openprd-session").textContent);
1017
- const localeConfig = JSON.parse(document.getElementById("openprd-canvas-locales").textContent);
1018
- const h = React.createElement;
1019
- const apiBase = "";
1020
- const sceneSaveDelay = 700;
1021
- const codexRelayStartWaitMs = 45000;
1022
- const codexRelayUiTimeoutMs = 60000;
1023
- const codexRelayCompletedStatuses = new Set(["completed"]);
1024
- let excalidrawAPI = null;
1025
- let latestElements = [];
1026
- let latestAppState = {};
1027
- let latestFiles = {};
1028
- let saveTimer = null;
1029
- let lastSeq = -1;
1030
- let pollTimer = null;
1031
- let syncSelectionState = () => {};
1032
- let syncAgentBridgeStatus = () => {};
1033
- let syncCodexRelayStatus = () => {};
1034
-
1035
- function normalizeLocale(locale) {
1036
- const value = String(locale || "").trim().toLowerCase();
1037
- if (value === "en" || value.startsWith("en-")) {
1038
- return "en";
1039
- }
1040
- if (value === "zh" || value.startsWith("zh-")) {
1041
- return "zh-CN";
1042
- }
1043
- return localeConfig.defaultLocale || "zh-CN";
1044
- }
1045
-
1046
- function initialLocale() {
1047
- try {
1048
- const saved = window.localStorage.getItem(localeConfig.storageKey);
1049
- if (saved) {
1050
- return normalizeLocale(saved);
1051
- }
1052
- } catch {}
1053
- const languages = Array.isArray(navigator.languages) && navigator.languages.length > 0
1054
- ? navigator.languages
1055
- : [navigator.language];
1056
- for (const language of languages) {
1057
- if (String(language || "").trim()) {
1058
- return normalizeLocale(language);
1059
- }
1060
- }
1061
- return localeConfig.defaultLocale || "zh-CN";
1062
- }
1063
-
1064
- function formatMessage(template, values = {}) {
1065
- return String(template || "").replace(/\\{(\\w+)\\}/g, (_, key) => {
1066
- return values[key] == null ? "" : String(values[key]);
1067
- });
1068
- }
1069
-
1070
- function messagesFor(locale) {
1071
- return localeConfig.messages[locale] || localeConfig.messages[localeConfig.defaultLocale] || {};
1072
- }
1073
-
1074
- const canvasSurfaceColor = "#ffffff";
1075
- const legacyCanvasBackgrounds = new Set(["#fbfaf7", "#fffdf9", "#f8f6f1"]);
1076
-
1077
- function normalizeCanvasAppState(appState = {}) {
1078
- const nextAppState = { ...(appState || {}) };
1079
- const current = String(nextAppState.viewBackgroundColor || "").trim().toLowerCase();
1080
- if (!current || legacyCanvasBackgrounds.has(current)) {
1081
- nextAppState.viewBackgroundColor = canvasSurfaceColor;
1082
- }
1083
- return nextAppState;
1084
- }
1085
-
1086
- function normalizeInitialScene(scene) {
1087
- const base = scene && Array.isArray(scene.elements)
1088
- ? scene
1089
- : { elements: [], appState: {}, files: {} };
1090
- return {
1091
- ...base,
1092
- elements: Array.isArray(base.elements) ? base.elements : [],
1093
- appState: normalizeCanvasAppState(base.appState),
1094
- files: base.files && typeof base.files === "object" ? base.files : {},
1095
- };
1096
- }
1097
-
1098
- function selectedCountFromAppState(appState) {
1099
- const selectedIds = appState?.selectedElementIds || {};
1100
- return Object.keys(selectedIds).filter((id) => selectedIds[id]).length;
1101
- }
1102
-
1103
- function selectedImageCountFromAppState(appState, elements = latestElements) {
1104
- const selectedIds = appState?.selectedElementIds || {};
1105
- return (elements || []).filter((element) => {
1106
- return element && !element.isDeleted && element.type === "image" && selectedIds[element.id];
1107
- }).length;
1108
- }
1109
-
1110
- function conversationTitle(t) {
1111
- return session.binding?.title || session.binding?.threadTitle || session.binding?.name || t.currentConversationTitle;
1112
- }
1113
-
1114
- function conversationId() {
1115
- return session.binding?.displayId || session.binding?.id || session.sessionKey;
1116
- }
1117
-
1118
- function toolIcon(kind) {
1119
- if (kind === "card") {
1120
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1121
- h("rect", { x: 4, y: 5, width: 16, height: 14, rx: 2 }),
1122
- h("path", { d: "M8 9h8" }),
1123
- h("path", { d: "M8 13h5" }),
1124
- );
1125
- }
1126
- if (kind === "export") {
1127
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1128
- h("rect", { x: 3, y: 5, width: 18, height: 14, rx: 2 }),
1129
- h("circle", { cx: 8, cy: 10, r: 1.6 }),
1130
- h("path", { d: "m21 16-5-5-4 4-2-2-5 5" }),
1131
- );
1132
- }
1133
- if (kind === "save") {
1134
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1135
- h("path", { d: "M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" }),
1136
- h("path", { d: "M17 21v-8H7v8" }),
1137
- h("path", { d: "M7 3v5h8" }),
1138
- );
1139
- }
1140
- if (kind === "reset") {
1141
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1142
- h("path", { d: "M3 6h18" }),
1143
- h("path", { d: "M8 6V4h8v2" }),
1144
- h("path", { d: "M19 6l-1 14H6L5 6" }),
1145
- h("path", { d: "M10 11v5" }),
1146
- h("path", { d: "M14 11v5" }),
1147
- );
1148
- }
1149
- if (kind === "library") {
1150
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1151
- h("path", { d: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20" }),
1152
- h("path", { d: "M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15Z" }),
1153
- );
1154
- }
1155
- if (kind === "send") {
1156
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1157
- h("path", { d: "M22 2 11 13" }),
1158
- h("path", { d: "m22 2-7 20-4-9-9-4 20-7z" }),
1159
- );
1160
- }
1161
- if (kind === "copy") {
1162
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1163
- h("rect", { x: 9, y: 9, width: 13, height: 13, rx: 2 }),
1164
- h("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" }),
1165
- );
1166
- }
1167
- return h("svg", { width: 17, height: 17, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
1168
- h("circle", { cx: 12, cy: 12, r: 9 }),
1169
- h("path", { d: "M12 8v4" }),
1170
- h("path", { d: "M12 16h.01" }),
1171
- );
1172
- }
1173
-
1174
- async function fetchJson(url, options = {}) {
1175
- const response = await fetch(apiBase + url, {
1176
- ...options,
1177
- headers: {
1178
- "content-type": "application/json",
1179
- ...(options.headers || {}),
1180
- },
1181
- });
1182
- if (!response.ok) {
1183
- const text = await response.text().catch(() => "");
1184
- throw new Error(text || response.statusText);
1185
- }
1186
- return response.json();
1187
- }
1188
-
1189
- async function withTimeout(promise, timeoutMs) {
1190
- let timer = null;
1191
- try {
1192
- return await Promise.race([
1193
- promise,
1194
- new Promise((_, reject) => {
1195
- timer = window.setTimeout(() => reject(new Error("timeout")), timeoutMs);
1196
- }),
1197
- ]);
1198
- } finally {
1199
- if (timer !== null) {
1200
- window.clearTimeout(timer);
1201
- }
1202
- }
1203
- }
1204
-
1205
- function relayWasAccepted(result) {
1206
- return Boolean(result?.relay?.accepted || result?.relay?.delivered || codexRelayCompletedStatuses.has(result?.relay?.status));
1207
- }
1208
-
1209
- function relayIsPending(result, timedOut) {
1210
- const status = result?.relay?.status;
1211
- return Boolean(timedOut || result?.relay?.pending || result?.relay?.waitTimedOut || ["queued", "starting", "resuming", "connecting", "started"].includes(status));
1212
- }
1213
-
1214
- function agentBridgeIsPending(result) {
1215
- const status = result?.agentBridge?.status;
1216
- return Boolean(result?.agentBridge?.supportsForegroundMessage && ["pending", "claimed"].includes(status));
1217
- }
1218
-
1219
- function agentBridgePendingMessage(t, status) {
1220
- return status === "claimed" ? t.agentBridgeClaimed : t.agentBridgePending;
1221
- }
1222
-
1223
- function stripAppState(appState) {
1224
- const {
1225
- collaborators,
1226
- contextMenu,
1227
- editingElement,
1228
- editingFrame,
1229
- editingGroupId,
1230
- errorMessage,
1231
- openDialog,
1232
- openMenu,
1233
- pendingImageElementId,
1234
- resizingElement,
1235
- selectionElement,
1236
- suggestedBindings,
1237
- toast,
1238
- ...rest
1239
- } = appState || {};
1240
- return rest;
1241
- }
1242
-
1243
- function libraryItemsFromPayload(payload) {
1244
- if (Array.isArray(payload?.libraryItems)) {
1245
- return payload.libraryItems;
1246
- }
1247
- if (Array.isArray(payload?.items)) {
1248
- return payload.items;
1249
- }
1250
- return [];
1251
- }
1252
-
1253
- function createElementId(prefix = "openprd-lib") {
1254
- if (window.crypto?.randomUUID) {
1255
- return prefix + "-" + window.crypto.randomUUID();
1256
- }
1257
- return prefix + "-" + Date.now() + "-" + Math.random().toString(36).slice(2);
1258
- }
1259
-
1260
- function imageTemplateOptions(t) {
1261
- return [
1262
- { id: "1-1", label: "1:1", title: t.aiTemplateSquare, width: 320, height: 320, aspectRatio: 1 },
1263
- { id: "3-4", label: "3:4", title: t.aiTemplatePortrait, width: 300, height: 400, aspectRatio: 0.75 },
1264
- { id: "4-3", label: "4:3", title: t.aiTemplateLandscape, width: 400, height: 300, aspectRatio: 1.3333333333 },
1265
- { id: "16-9", label: "16:9", title: t.aiTemplateWide, width: 480, height: 270, aspectRatio: 1.7777777778 },
1266
- { id: "9-16", label: "9:16", title: t.aiTemplateVertical, width: 270, height: 480, aspectRatio: 0.5625 },
1267
- { id: "wechat-2-35", label: "2.35:1", title: t.aiTemplateWechat, width: 520, height: 221, aspectRatio: 2.35 },
1268
- ];
1269
- }
1270
-
1271
- function cloneLibraryElement(element, offsetX, offsetY) {
1272
- return {
1273
- ...element,
1274
- id: createElementId(),
1275
- x: Number(element.x || 0) + offsetX,
1276
- y: Number(element.y || 0) + offsetY,
1277
- seed: Math.floor(Math.random() * 2147483647),
1278
- version: 1,
1279
- versionNonce: Math.floor(Math.random() * 2147483647),
1280
- updated: Date.now(),
1281
- isDeleted: false,
1282
- };
1283
- }
1284
-
1285
- function boundsForElements(elements) {
1286
- const values = elements
1287
- .filter((element) => element && !element.isDeleted)
1288
- .map((element) => ({
1289
- minX: Number(element.x || 0),
1290
- minY: Number(element.y || 0),
1291
- maxX: Number(element.x || 0) + Number(element.width || 0),
1292
- maxY: Number(element.y || 0) + Number(element.height || 0),
1293
- }));
1294
- if (values.length === 0) {
1295
- return { x: 0, y: 0, width: 360, height: 240 };
1296
- }
1297
- const minX = Math.min(...values.map((value) => value.minX));
1298
- const minY = Math.min(...values.map((value) => value.minY));
1299
- const maxX = Math.max(...values.map((value) => value.maxX));
1300
- const maxY = Math.max(...values.map((value) => value.maxY));
1301
- return {
1302
- x: minX,
1303
- y: minY,
1304
- width: Math.max(80, maxX - minX),
1305
- height: Math.max(80, maxY - minY),
1306
- };
1307
- }
1308
-
1309
- function queueSave() {
1310
- window.clearTimeout(saveTimer);
1311
- saveTimer = window.setTimeout(async () => {
1312
- await fetchJson("/api/scene", {
1313
- method: "POST",
1314
- body: JSON.stringify({
1315
- type: "openprd.canvas.scene.v1",
1316
- elements: latestElements,
1317
- appState: stripAppState(latestAppState),
1318
- files: latestFiles,
1319
- savedAt: new Date().toISOString(),
1320
- }),
1321
- }).catch((error) => {
1322
- console.warn("OpenPrd canvas save failed", error);
1323
- });
1324
- }, sceneSaveDelay);
1325
- }
1326
-
1327
- function getViewportDropPoint(width, height) {
1328
- const appState = latestAppState || {};
1329
- const scrollX = Number(appState.scrollX || 0);
1330
- const scrollY = Number(appState.scrollY || 0);
1331
- const zoomValue = Number(appState.zoom?.value || 1);
1332
- const canvasWidth = window.innerWidth - 232;
1333
- const canvasHeight = window.innerHeight - 48;
1334
- return {
1335
- x: Math.round((canvasWidth / 2 - scrollX) / zoomValue - width / 2),
1336
- y: Math.round((canvasHeight / 2 - scrollY) / zoomValue - height / 2),
1337
- };
1338
- }
1339
-
1340
- function selectedElement() {
1341
- const selectedIds = latestAppState?.selectedElementIds || {};
1342
- return latestElements.find((element) => selectedIds[element.id] && !element.isDeleted) || null;
1343
- }
1344
-
1345
- function currentVisibleElements() {
1346
- return latestElements.filter((element) => !element.isDeleted);
1347
- }
1348
-
1349
- function currentSelectedElements() {
1350
- const selectedIds = latestAppState?.selectedElementIds || {};
1351
- return currentVisibleElements().filter((element) => selectedIds[element.id]);
1352
- }
1353
-
1354
- function createExportFilename(prefix = "canvas-selection") {
1355
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1356
- return prefix + "-" + stamp + ".png";
1357
- }
1358
-
1359
- function createSceneFilename(prefix = "openprd-canvas") {
1360
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1361
- return prefix + "-" + stamp + ".excalidraw";
1362
- }
1363
-
1364
- function downloadBlob(blob, filename) {
1365
- const url = URL.createObjectURL(blob);
1366
- const anchor = document.createElement("a");
1367
- anchor.href = url;
1368
- anchor.download = filename;
1369
- anchor.rel = "noopener";
1370
- document.body.appendChild(anchor);
1371
- anchor.click();
1372
- document.body.removeChild(anchor);
1373
- window.setTimeout(() => URL.revokeObjectURL(url), 1000);
1374
- }
1375
-
1376
- function downloadUrl(url, filename) {
1377
- if (!url) {
1378
- throw new Error("missing-export-url");
1379
- }
1380
- const anchor = document.createElement("a");
1381
- anchor.href = url;
1382
- anchor.download = filename || createExportFilename("canvas-export");
1383
- anchor.rel = "noopener";
1384
- document.body.appendChild(anchor);
1385
- anchor.click();
1386
- document.body.removeChild(anchor);
1387
- }
1388
-
1389
- function currentSceneElementsForFile() {
1390
- return excalidrawAPI?.getSceneElements?.() || latestElements;
1391
- }
1392
-
1393
- function currentFilesForFile() {
1394
- return excalidrawAPI?.getFiles?.() || latestFiles;
1395
- }
1396
-
1397
- function currentAppStateForFile() {
1398
- return normalizeCanvasAppState(excalidrawAPI?.getAppState?.() || latestAppState);
1399
- }
1400
-
1401
- async function saveCanvasToDisk() {
1402
- const elements = currentSceneElementsForFile();
1403
- const appState = stripAppState(currentAppStateForFile());
1404
- const files = currentFilesForFile();
1405
- const content = serializeAsJSON
1406
- ? serializeAsJSON(elements, appState, files, "local")
1407
- : JSON.stringify({ type: "excalidraw", version: 2, source: "https://openprd.local/canvas", elements, appState, files }, null, 2);
1408
- const filename = createSceneFilename();
1409
- await fetchJson("/api/download-file", {
1410
- method: "POST",
1411
- body: JSON.stringify({
1412
- filename,
1413
- content,
1414
- mimeType: "application/json; charset=utf-8",
1415
- }),
1416
- });
1417
- queueSave();
1418
- return { filename };
1419
- }
1420
-
1421
- async function exportCanvasImage(locale) {
1422
- const filename = createExportFilename("canvas-export");
1423
- const exported = await exportCanvasElements({ intent: "manual-export", locale, filename });
1424
- const exportData = exported.result.export || {};
1425
- downloadUrl(exportData.exportUrl, exportData.fileName || filename);
1426
- return exportData;
1427
- }
1428
-
1429
- function hideNativeExcalidrawChrome() {
1430
- const root = document.querySelector(".canvas-wrap .excalidraw");
1431
- if (!root) {
1432
- return;
1433
- }
1434
- const fixedSelectors = [
1435
- '[data-testid="main-menu-trigger"]',
1436
- ".main-menu-trigger",
1437
- '[data-testid="library-button"]',
1438
- ".library-button",
1439
- ".default-sidebar-trigger",
1440
- ".sidebar-trigger__label-element",
1441
- 'button[aria-label="Library"]',
1442
- 'button[aria-label="素材库"]',
1443
- 'button[title="Library"]',
1444
- 'button[title="素材库"]',
1445
- ];
1446
- root.querySelectorAll(fixedSelectors.join(",")).forEach((node) => {
1447
- node.setAttribute("hidden", "");
1448
- node.style.display = "none";
1449
- });
1450
- root.querySelectorAll("button").forEach((button) => {
1451
- const label = [
1452
- button.textContent,
1453
- button.getAttribute("aria-label"),
1454
- button.getAttribute("title"),
1455
- ].join(" ").trim().toLowerCase();
1456
- if (label === "library" || label === "素材库") {
1457
- button.setAttribute("hidden", "");
1458
- button.style.display = "none";
1459
- }
1460
- });
1461
- }
1462
-
1463
- async function copyTextToClipboard(text) {
1464
- let copied = false;
1465
- try {
1466
- await withTimeout(navigator.clipboard.writeText(text), 900);
1467
- copied = true;
1468
- } catch {}
1469
- try {
1470
- const textarea = document.createElement("textarea");
1471
- textarea.value = text;
1472
- textarea.setAttribute("readonly", "");
1473
- textarea.style.position = "fixed";
1474
- textarea.style.left = "-9999px";
1475
- document.body.appendChild(textarea);
1476
- textarea.select();
1477
- const ok = document.execCommand("copy");
1478
- document.body.removeChild(textarea);
1479
- copied = copied || ok;
1480
- } catch {
1481
- copied = copied || false;
1482
- }
1483
- try {
1484
- await withTimeout(fetchJson("/api/clipboard", {
1485
- method: "POST",
1486
- body: JSON.stringify({ text }),
1487
- }), 1200);
1488
- return true;
1489
- } catch {}
1490
- return copied;
1491
- }
1492
-
1493
- function buildCodexPrompt(t, exportPath, elementCount, instruction) {
1494
- const binding = conversationId();
1495
- const trimmedInstruction = String(instruction || "").trim();
1496
- return [
1497
- trimmedInstruction || t.codexPromptDefaultInstruction,
1498
- "",
1499
- t.codexPromptIntro,
1500
- "",
1501
- "![OpenPrd Canvas selection](" + exportPath + ")",
1502
- "",
1503
- exportPath,
1504
- "",
1505
- formatMessage(t.codexPromptMeta, { count: elementCount, binding }),
1506
- ].join("\\n");
1507
- }
1508
-
1509
- function buildThreadPrompt(t) {
1510
- return formatMessage(t.threadPromptTemplate, {
1511
- title: conversationTitle(t),
1512
- id: conversationId(),
1513
- });
1514
- }
1515
-
1516
- async function recordCodexHandoff(exported, promptText, locale, instruction) {
1517
- const operation = exported.result.operation || {};
1518
- const exportData = exported.result.export || {};
1519
- return fetchJson("/api/handoffs", {
1520
- method: "POST",
1521
- body: JSON.stringify({
1522
- target: "codex",
1523
- transport: "codex-app-server-codex-delegation-turn-start",
1524
- agentBridge: true,
1525
- relay: true,
1526
- exportOperationId: operation.id || null,
1527
- exportPath: exportData.exportPath || "",
1528
- exportUrl: exportData.exportUrl || "",
1529
- promptText,
1530
- selection: operation.selection || (exported.selected.length > 0 ? "selected" : "scene"),
1531
- selectedElementIds: exported.selected.map((element) => element.id),
1532
- elementCount: exported.elements.length,
1533
- locale,
1534
- instruction,
1535
- relayWaitMs: codexRelayStartWaitMs,
1536
- note: "canvas-send-to-codex",
1537
- }),
1538
- });
1539
- }
1540
-
1541
- async function recordCodexImageRequestHandoff(promptText, locale, instruction, template, created) {
1542
- return fetchJson("/api/handoffs", {
1543
- method: "POST",
1544
- body: JSON.stringify({
1545
- target: "codex",
1546
- transport: "codex-app-server-codex-delegation-turn-start",
1547
- agentBridge: true,
1548
- relay: true,
1549
- promptText,
1550
- instruction,
1551
- selection: "ai-image-template",
1552
- placeholderId: created.placeholderId,
1553
- aspectRatio: template.aspectRatio,
1554
- mode: "replace",
1555
- elementCount: created.elementIds.length,
1556
- selectedElementIds: created.elementIds,
1557
- locale,
1558
- relayWaitMs: codexRelayStartWaitMs,
1559
- note: "canvas-ai-image-template",
1560
- }),
1561
- });
1562
- }
1563
-
1564
- function buildImageRequestPrompt(t, template, instruction, placeholderId) {
1565
- const binding = conversationId();
1566
- const trimmedInstruction = String(instruction || "").trim();
1567
- return [
1568
- trimmedInstruction,
1569
- "",
1570
- t.aiImagePromptIntro,
1571
- "",
1572
- formatMessage(t.aiImagePromptMeta, {
1573
- ratio: template.label,
1574
- placeholderId,
1575
- binding,
1576
- }),
1577
- "",
1578
- t.aiImagePromptReturn,
1579
- "",
1580
- JSON.stringify({
1581
- placeholderId,
1582
- aspectRatio: template.aspectRatio,
1583
- mode: "replace",
1584
- note: "ai-image-template",
1585
- }, null, 2),
1586
- ].join("\\n");
1587
- }
1588
-
1589
- function latestPlaceholder() {
1590
- return [...latestElements].reverse().find((element) => {
1591
- const role = element.customData?.openprdRole;
1592
- return !element.isDeleted && (role === "image-placeholder" || role === "ai-image-placeholder");
1593
- }) || null;
1594
- }
1595
-
1596
- function fitIntoBox(box, aspectRatio) {
1597
- const ratio = Number(aspectRatio || 1.5);
1598
- let width = Math.max(80, Number(box.width || 360));
1599
- let height = Math.max(80, Math.round(width / ratio));
1600
- if (height > Number(box.height || height)) {
1601
- height = Math.max(80, Number(box.height || 240));
1602
- width = Math.round(height * ratio);
1603
- }
1604
- return {
1605
- x: Math.round(Number(box.x || 0) + (Number(box.width || width) - width) / 2),
1606
- y: Math.round(Number(box.y || 0) + (Number(box.height || height) - height) / 2),
1607
- width,
1608
- height,
1609
- };
1610
- }
1611
-
1612
- function updateScene(elements, appStatePatch = null) {
1613
- if (!excalidrawAPI) {
1614
- return;
1615
- }
1616
- const nextAppState = appStatePatch
1617
- ? normalizeCanvasAppState({ ...latestAppState, ...appStatePatch })
1618
- : latestAppState;
1619
- excalidrawAPI.updateScene(appStatePatch ? { elements, appState: nextAppState } : { elements });
1620
- latestElements = elements;
1621
- if (appStatePatch) {
1622
- latestAppState = nextAppState;
1623
- }
1624
- syncSelectionState(latestAppState, latestElements);
1625
- queueSave();
1626
- }
1627
-
1628
- function createAiImageCard(template, instruction, t) {
1629
- const point = getViewportDropPoint(template.width, template.height);
1630
- const requestId = createElementId("openprd-ai-image");
1631
- const groupId = createElementId("openprd-ai-group");
1632
- const centerY = point.y + Math.round(template.height / 2);
1633
- const pendingParent = {
1634
- openprdParentId: "__pending__",
1635
- openprdRequestId: requestId,
1636
- openprdGroupId: groupId,
1637
- };
1638
- let elements = convertToExcalidrawElements([
1639
- {
1640
- type: "rectangle",
1641
- x: point.x,
1642
- y: point.y,
1643
- width: template.width,
1644
- height: template.height,
1645
- backgroundColor: "#eff6ff",
1646
- strokeColor: "#2563eb",
1647
- strokeStyle: "dashed",
1648
- fillStyle: "hachure",
1649
- roughness: 1,
1650
- customData: {
1651
- openprdRole: "ai-image-placeholder",
1652
- openprdStatus: "generating",
1653
- openprdRequestId: requestId,
1654
- openprdPrompt: instruction,
1655
- openprdAspectRatio: template.aspectRatio,
1656
- openprdTemplateId: template.id,
1657
- openprdTemplateLabel: template.label,
1658
- },
1659
- },
1660
- {
1661
- type: "rectangle",
1662
- x: point.x + 18,
1663
- y: point.y + 18,
1664
- width: 58,
1665
- height: 28,
1666
- backgroundColor: "#2563eb",
1667
- strokeColor: "#2563eb",
1668
- roughness: 0,
1669
- customData: { ...pendingParent, openprdRole: "ai-image-badge" },
1670
- },
1671
- {
1672
- type: "text",
1673
- x: point.x + 36,
1674
- y: point.y + 24,
1675
- text: "AI",
1676
- fontSize: 14,
1677
- strokeColor: "#ffffff",
1678
- customData: { ...pendingParent, openprdRole: "ai-image-badge-label" },
1679
- },
1680
- {
1681
- type: "rectangle",
1682
- x: point.x + Math.round(template.width * 0.18),
1683
- y: point.y + Math.round(template.height * 0.28),
1684
- width: Math.round(template.width * 0.64),
1685
- height: 12,
1686
- backgroundColor: "#bfdbfe",
1687
- strokeColor: "#bfdbfe",
1688
- opacity: 38,
1689
- roughness: 0,
1690
- customData: { ...pendingParent, openprdRole: "ai-image-ripple" },
1691
- },
1692
- {
1693
- type: "rectangle",
1694
- x: point.x + Math.round(template.width * 0.24),
1695
- y: point.y + Math.round(template.height * 0.36),
1696
- width: Math.round(template.width * 0.52),
1697
- height: 10,
1698
- backgroundColor: "#bfdbfe",
1699
- strokeColor: "#bfdbfe",
1700
- opacity: 26,
1701
- roughness: 0,
1702
- customData: { ...pendingParent, openprdRole: "ai-image-ripple" },
1703
- },
1704
- {
1705
- type: "text",
1706
- x: point.x + 24,
1707
- y: centerY - 32,
1708
- text: t.aiGenerating,
1709
- fontSize: 22,
1710
- strokeColor: "#1e3a8a",
1711
- customData: { ...pendingParent, openprdRole: "ai-image-status-label" },
1712
- },
1713
- {
1714
- type: "text",
1715
- x: point.x + 24,
1716
- y: centerY + 4,
1717
- text: formatMessage(t.aiTemplateOnCanvas, { ratio: template.label }),
1718
- fontSize: 15,
1719
- strokeColor: "#64748b",
1720
- customData: { ...pendingParent, openprdRole: "ai-image-ratio-label" },
1721
- },
1722
- ]);
1723
- const placeholder = elements.find((element) => element.customData?.openprdRole === "ai-image-placeholder");
1724
- const placeholderId = placeholder?.id || requestId;
1725
- elements = elements.map((element) => {
1726
- const groupedElement = {
1727
- ...element,
1728
- groupIds: [groupId],
1729
- };
1730
- if (element.customData?.openprdParentId !== "__pending__") {
1731
- return {
1732
- ...groupedElement,
1733
- customData: {
1734
- ...groupedElement.customData,
1735
- openprdGroupId: groupId,
1736
- },
1737
- };
1738
- }
1739
- return {
1740
- ...groupedElement,
1741
- customData: {
1742
- ...groupedElement.customData,
1743
- openprdParentId: placeholderId,
1744
- },
1745
- };
1746
- });
1747
- const selectedElementIds = Object.fromEntries(elements.map((element) => [element.id, true]));
1748
- updateScene([...latestElements, ...elements], {
1749
- selectedElementIds,
1750
- });
1751
- return {
1752
- placeholderId,
1753
- groupId,
1754
- elementIds: elements.map((element) => element.id),
1755
- };
1756
- }
1757
-
1758
- function attachAiImagePrompt(placeholderId, promptText) {
1759
- updateScene(latestElements.map((element) => {
1760
- if (element.id !== placeholderId) {
1761
- return element;
1762
- }
1763
- return {
1764
- ...element,
1765
- customData: {
1766
- ...element.customData,
1767
- openprdPromptText: promptText,
1768
- },
1769
- };
1770
- }));
1771
- }
1772
-
1773
- function groupIdsForElement(element) {
1774
- const ids = new Set(Array.isArray(element?.groupIds) ? element.groupIds.filter(Boolean) : []);
1775
- if (element?.customData?.openprdGroupId) {
1776
- ids.add(element.customData.openprdGroupId);
1777
- }
1778
- return ids;
1779
- }
1780
-
1781
- async function addBlankCard(labelText = messagesFor(initialLocale()).placeholderText) {
1782
- const point = getViewportDropPoint(360, 240);
1783
- const elements = convertToExcalidrawElements([
1784
- {
1785
- type: "rectangle",
1786
- x: point.x,
1787
- y: point.y,
1788
- width: 360,
1789
- height: 240,
1790
- backgroundColor: "#ffffff",
1791
- strokeColor: "#2563eb",
1792
- roughness: 1,
1793
- customData: { openprdRole: "image-placeholder" },
1794
- },
1795
- {
1796
- type: "text",
1797
- x: point.x + 22,
1798
- y: point.y + 104,
1799
- text: labelText,
1800
- fontSize: 18,
1801
- strokeColor: "#65717f",
1802
- customData: { openprdRole: "image-placeholder-label" },
1803
- },
1804
- ]);
1805
- updateScene([...latestElements, ...elements]);
1806
- await fetchJson("/api/cards", {
1807
- method: "POST",
1808
- body: JSON.stringify({ source: "browser", note: "blank-card" }),
1809
- }).catch(() => {});
1810
- }
1811
-
1812
- function measureDataUrl(dataUrl) {
1813
- return new Promise((resolve) => {
1814
- const image = new Image();
1815
- image.onload = () => resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 768 });
1816
- image.onerror = () => resolve({ width: 1024, height: 768 });
1817
- image.src = dataUrl;
1818
- });
1819
- }
1820
-
1821
- async function importReviewDirectionsToCanvas(t) {
1822
- const payload = await fetchJson("/api/review-directions");
1823
- const directions = Array.isArray(payload.directions) ? payload.directions : [];
1824
- if (!payload.ok || directions.length === 0) {
1825
- const error = new Error(payload.reason || "no-directions");
1826
- error.code = payload.reason === "no-directions" || payload.reason === "no-version" ? "empty" : "failed";
1827
- throw error;
1828
- }
1829
- const cardWidth = 460;
1830
- const gap = 60;
1831
- const headerHeight = 96;
1832
- const measured = [];
1833
- for (const direction of directions) {
1834
- const size = direction.image?.dataUrl ? await measureDataUrl(direction.image.dataUrl) : { width: 4, height: 3 };
1835
- measured.push({ direction, imageHeight: Math.round(cardWidth * (size.height / size.width)) });
1836
- }
1837
- const maxImageHeight = Math.max(...measured.map((item) => item.imageHeight));
1838
- const cardHeight = headerHeight + maxImageHeight + 24;
1839
- const boardWidth = directions.length * cardWidth + (directions.length - 1) * gap;
1840
- const origin = getViewportDropPoint(boardWidth, cardHeight);
1841
- const files = [];
1842
- const skeletons = [];
1843
- measured.forEach((item, index) => {
1844
- const direction = item.direction;
1845
- const x = origin.x + index * (cardWidth + gap);
1846
- const y = origin.y;
1847
- const isSelected = payload.selectedDirectionId && direction.id === payload.selectedDirectionId;
1848
- skeletons.push({
1849
- type: "rectangle",
1850
- x: x - 16,
1851
- y: y - 16,
1852
- width: cardWidth + 32,
1853
- height: cardHeight + 32,
1854
- backgroundColor: isSelected ? "#eef2ff" : "#ffffff",
1855
- strokeColor: isSelected ? "#4f46e5" : "#cbd5e1",
1856
- roughness: 0,
1857
- customData: { openprdRole: "review-direction-frame", openprdDirectionId: direction.id },
1858
- });
1859
- skeletons.push({
1860
- type: "text",
1861
- x: x,
1862
- y: y,
1863
- text: (index + 1) + ". " + (direction.title || direction.id) + (isSelected ? " [" + t.reviewDirectionSelectedTag + "]" : ""),
1864
- fontSize: 20,
1865
- strokeColor: "#0f172a",
1866
- customData: { openprdRole: "review-direction-title", openprdDirectionId: direction.id },
1867
- });
1868
- if (direction.summary) {
1869
- skeletons.push({
1870
- type: "text",
1871
- x: x,
1872
- y: y + 34,
1873
- text: direction.summary + (direction.tradeoff ? "\\n" + direction.tradeoff : ""),
1874
- fontSize: 14,
1875
- strokeColor: "#475569",
1876
- customData: { openprdRole: "review-direction-summary", openprdDirectionId: direction.id },
1877
- });
1878
- }
1879
- if (direction.image?.dataUrl) {
1880
- const fileId = "openprd-direction-" + direction.id + "-" + Date.now();
1881
- files.push({
1882
- id: fileId,
1883
- dataURL: direction.image.dataUrl,
1884
- mimeType: direction.image.mimeType || "image/png",
1885
- created: Date.now(),
1886
- });
1887
- skeletons.push({
1888
- type: "image",
1889
- x: x,
1890
- y: y + headerHeight,
1891
- width: cardWidth,
1892
- height: item.imageHeight,
1893
- fileId,
1894
- customData: { openprdRole: "review-direction-image", openprdDirectionId: direction.id },
1895
- });
1896
- }
1897
- });
1898
- if (excalidrawAPI && files.length > 0) {
1899
- excalidrawAPI.addFiles(files);
1900
- }
1901
- const elements = convertToExcalidrawElements(skeletons);
1902
- updateScene([...latestElements, ...elements]);
1903
- if (excalidrawAPI) {
1904
- excalidrawAPI.scrollToContent(elements, { fitToViewport: true, viewportZoomFactor: 0.85 });
1905
- }
1906
- return { count: directions.length };
1907
- }
1908
-
1909
- async function applyImageOperation(op) {
1910
- if (!excalidrawAPI) {
1911
- return;
1912
- }
1913
- const response = await fetch(op.assetUrl);
1914
- if (!response.ok) {
1915
- throw new Error("image fetch failed");
1916
- }
1917
- const blob = await response.blob();
1918
- const dataURL = await new Promise((resolve, reject) => {
1919
- const reader = new FileReader();
1920
- reader.onload = () => resolve(reader.result);
1921
- reader.onerror = reject;
1922
- reader.readAsDataURL(blob);
1923
- });
1924
- const fileId = op.fileId || ("openprd-image-" + Date.now());
1925
- excalidrawAPI.addFiles([{ id: fileId, dataURL, mimeType: blob.type || "image/png", created: Date.now() }]);
1926
- const explicitTarget = op.placeholderId
1927
- ? latestElements.find((element) => element.id === op.placeholderId && !element.isDeleted)
1928
- : null;
1929
- const target = explicitTarget || selectedElement() || latestPlaceholder();
1930
- const imageBox = target
1931
- ? fitIntoBox(target, op.aspectRatio)
1932
- : { ...getViewportDropPoint(480, 320), width: 480, height: 320 };
1933
- const [imageElement] = convertToExcalidrawElements([
1934
- {
1935
- type: "image",
1936
- x: imageBox.x,
1937
- y: imageBox.y,
1938
- width: imageBox.width,
1939
- height: imageBox.height,
1940
- fileId,
1941
- customData: {
1942
- openprdRole: "agent-image",
1943
- sourceOperationId: op.id,
1944
- sourcePath: op.sourcePath || null,
1945
- sourcePlaceholderId: target?.id || null,
1946
- },
1947
- },
1948
- ]);
1949
- const hiddenTargetIds = new Set();
1950
- if (target && op.mode !== "append") {
1951
- const hiddenGroupIds = groupIdsForElement(target);
1952
- const targetParentId = target.customData?.openprdParentId || null;
1953
- hiddenTargetIds.add(target.id);
1954
- if (targetParentId) {
1955
- hiddenTargetIds.add(targetParentId);
1956
- }
1957
- latestElements.forEach((element) => {
1958
- const elementGroupIds = groupIdsForElement(element);
1959
- const isSameGroup = [...hiddenGroupIds].some((groupId) => elementGroupIds.has(groupId));
1960
- const hasTargetAsParent = element.customData?.openprdParentId === target.id;
1961
- const sharesTargetParent = Boolean(targetParentId && element.customData?.openprdParentId === targetParentId);
1962
- if (
1963
- isSameGroup
1964
- || hasTargetAsParent
1965
- || sharesTargetParent
1966
- ) {
1967
- hiddenTargetIds.add(element.id);
1968
- }
1969
- });
1970
- }
1971
- const nextElements = latestElements
1972
- .map((element) => hiddenTargetIds.has(element.id) ? { ...element, isDeleted: true } : element)
1973
- .concat(imageElement);
1974
- updateScene(nextElements);
1975
- }
1976
-
1977
- async function applyCardOperation(op) {
1978
- if (op.source === "browser") {
1979
- return;
1980
- }
1981
- await addBlankCard();
1982
- }
1983
-
1984
- async function pollOperations() {
1985
- try {
1986
- const result = await fetchJson("/api/ops?after=" + encodeURIComponent(String(lastSeq)));
1987
- for (const op of result.ops || []) {
1988
- lastSeq = Math.max(lastSeq, Number(op.seq || 0));
1989
- if (op.type === "image") {
1990
- await applyImageOperation(op);
1991
- }
1992
- if (op.type === "card") {
1993
- await applyCardOperation(op);
1994
- }
1995
- if (op.type === "handoff-relay") {
1996
- syncCodexRelayStatus(op);
1997
- }
1998
- if (op.type === "agent-foreground-relay" || op.type === "agent-foreground-relay-status") {
1999
- syncAgentBridgeStatus(op);
2000
- }
2001
- }
2002
- } catch (error) {
2003
- console.warn("OpenPrd canvas op polling failed", error);
2004
- }
2005
- }
2006
-
2007
- async function readAgentBridgeOutbox() {
2008
- try {
2009
- const result = await fetchJson("/api/agent-bridge");
2010
- const pending = Array.isArray(result.pending) ? result.pending : [];
2011
- if (pending.length === 0) {
2012
- return;
2013
- }
2014
- const claimed = pending.find((item) => item.status === "claimed");
2015
- const item = claimed || pending[0];
2016
- syncAgentBridgeStatus({ status: item.status || "pending" });
2017
- } catch (error) {
2018
- console.warn("OpenPrd canvas agent bridge polling failed", error);
2019
- }
2020
- }
2021
-
2022
- async function exportCanvasElements(options = {}) {
2023
- if (!excalidrawAPI || !exportToCanvas) {
2024
- throw new Error("Canvas API is not ready.");
2025
- }
2026
- const visibleElements = currentVisibleElements();
2027
- const selected = currentSelectedElements();
2028
- if (options.requireSelection && selected.length === 0) {
2029
- const error = new Error("empty-selection");
2030
- error.code = "empty-selection";
2031
- throw error;
2032
- }
2033
- const elements = selected.length > 0 ? selected : visibleElements;
2034
- if (elements.length === 0) {
2035
- const error = new Error("empty-scene");
2036
- error.code = "empty-scene";
2037
- throw error;
2038
- }
2039
- const canvas = await exportToCanvas({
2040
- elements,
2041
- appState: {
2042
- ...stripAppState(latestAppState),
2043
- exportBackground: true,
2044
- viewBackgroundColor: latestAppState?.viewBackgroundColor || "#ffffff",
2045
- },
2046
- files: excalidrawAPI.getFiles(),
2047
- exportPadding: 24,
2048
- maxWidthOrHeight: 2400,
2049
- });
2050
- const dataUrl = canvas.toDataURL("image/png");
2051
- const intent = options.intent || "manual-export";
2052
- const result = await fetchJson("/api/exports", {
2053
- method: "POST",
2054
- body: JSON.stringify({
2055
- dataUrl,
2056
- selection: selected.length > 0 ? "selected" : "scene",
2057
- selectedElementIds: selected.map((element) => element.id),
2058
- elementCount: elements.length,
2059
- intent,
2060
- locale: options.locale || null,
2061
- filename: options.filename || null,
2062
- }),
2063
- });
2064
- window.dispatchEvent(new CustomEvent("openprd-canvas-exported", { detail: { ...result, intent } }));
2065
- return {
2066
- result,
2067
- elements,
2068
- selected,
2069
- };
2070
- }
2071
-
2072
- async function exportSelection() {
2073
- return exportCanvasElements({ intent: "manual-export" }).catch((error) => {
2074
- console.warn("OpenPrd canvas export failed", error);
2075
- return null;
2076
- });
2077
- }
2078
-
2079
- function CanvasApp() {
2080
- const [initialData, setInitialData] = React.useState(null);
2081
- const [loadError, setLoadError] = React.useState(null);
2082
- const [locale, setLocale] = React.useState(initialLocale);
2083
- const [selectedCount, setSelectedCount] = React.useState(0);
2084
- const [selectedImageCount, setSelectedImageCount] = React.useState(0);
2085
- const [isSending, setIsSending] = React.useState(false);
2086
- const [isExporting, setIsExporting] = React.useState(false);
2087
- const [isDownloadingSelection, setIsDownloadingSelection] = React.useState(false);
2088
- const [isOpeningFolder, setIsOpeningFolder] = React.useState(false);
2089
- const [actionStatus, setActionStatus] = React.useState(null);
2090
- const [composer, setComposer] = React.useState(null);
2091
- const [imageComposer, setImageComposer] = React.useState(null);
2092
- const [isRequestingImage, setIsRequestingImage] = React.useState(false);
2093
- const [libraryItems, setLibraryItems] = React.useState([]);
2094
- const [libraryPreviews, setLibraryPreviews] = React.useState({});
2095
- const [guide, setGuide] = React.useState(null);
2096
- const [isImportingDirections, setIsImportingDirections] = React.useState(false);
2097
- const latestHandoffSignalSeqRef = React.useRef(0);
2098
- const t = messagesFor(locale);
2099
- const aiTemplates = React.useMemo(() => imageTemplateOptions(t), [locale]);
2100
-
2101
- React.useEffect(() => {
2102
- syncSelectionState = (appState, elements) => {
2103
- setSelectedCount(selectedCountFromAppState(appState));
2104
- setSelectedImageCount(selectedImageCountFromAppState(appState, elements));
2105
- };
2106
- return () => {
2107
- syncSelectionState = () => {};
2108
- };
2109
- }, []);
2110
-
2111
- React.useEffect(() => {
2112
- const shouldApplyHandoffSignal = (op) => {
2113
- const seq = Number(op.seq || 0);
2114
- if (seq > 0 && seq < latestHandoffSignalSeqRef.current) {
2115
- return false;
2116
- }
2117
- if (seq > 0) {
2118
- latestHandoffSignalSeqRef.current = seq;
2119
- }
2120
- return true;
2121
- };
2122
- syncCodexRelayStatus = (op) => {
2123
- if (!shouldApplyHandoffSignal(op)) {
2124
- return;
2125
- }
2126
- if (op.dryRun) {
2127
- setActionStatus(null);
2128
- return;
2129
- }
2130
- if (op.status === "completed" || op.status === "started") {
2131
- setActionStatus({ type: "success", message: t.sentBodyWithRelay });
2132
- } else if (["queued", "starting", "resuming", "connecting"].includes(op.status)) {
2133
- setActionStatus({ type: "busy", message: t.sentBodyPending });
2134
- } else if (op.status === "failed" || op.status === "timeout" || op.status === "closed") {
2135
- setActionStatus({ type: "error", message: op.error || t.agentBridgeFailed });
2136
- }
2137
- };
2138
- syncAgentBridgeStatus = (op) => {
2139
- if (!shouldApplyHandoffSignal(op)) {
2140
- return;
2141
- }
2142
- if (op.status === "sent") {
2143
- setActionStatus({ type: "success", message: t.agentBridgeSent });
2144
- } else if (op.status === "failed") {
2145
- setActionStatus({ type: "error", message: op.reason || t.agentBridgeFailed });
2146
- } else if (op.status === "pending" || op.status === "claimed") {
2147
- setActionStatus({ type: "busy", message: agentBridgePendingMessage(t, op.status) });
2148
- }
2149
- };
2150
- return () => {
2151
- syncCodexRelayStatus = () => {};
2152
- syncAgentBridgeStatus = () => {};
2153
- };
2154
- }, [t]);
2155
-
2156
- React.useEffect(() => {
2157
- document.documentElement.lang = locale;
2158
- try {
2159
- window.localStorage.setItem(localeConfig.storageKey, locale);
2160
- } catch {}
2161
- }, [locale]);
2162
-
2163
- React.useEffect(() => {
2164
- let mounted = true;
2165
- Promise.all([
2166
- fetchJson("/api/scene"),
2167
- fetchJson("/api/library").catch(() => ({ libraryItems: [] })),
2168
- ])
2169
- .then(([scene, library]) => {
2170
- if (mounted) {
2171
- const libraryItems = libraryItemsFromPayload(library);
2172
- const normalizedScene = normalizeInitialScene(scene);
2173
- setInitialData({
2174
- ...normalizedScene,
2175
- libraryItems,
2176
- });
2177
- setLibraryItems(libraryItems);
2178
- latestElements = normalizedScene.elements;
2179
- latestAppState = normalizedScene.appState;
2180
- latestFiles = normalizedScene.files;
2181
- setSelectedCount(selectedCountFromAppState(normalizedScene.appState));
2182
- setSelectedImageCount(selectedImageCountFromAppState(normalizedScene.appState, normalizedScene.elements));
2183
- if (scene?.appState?.viewBackgroundColor !== normalizedScene.appState.viewBackgroundColor) {
2184
- fetchJson("/api/scene", {
2185
- method: "POST",
2186
- body: JSON.stringify({
2187
- type: "openprd.canvas.scene.v1",
2188
- elements: normalizedScene.elements,
2189
- appState: stripAppState(normalizedScene.appState),
2190
- files: normalizedScene.files,
2191
- savedAt: new Date().toISOString(),
2192
- }),
2193
- }).catch((error) => {
2194
- console.warn("OpenPrd canvas background migration failed", error);
2195
- });
2196
- }
2197
- }
2198
- })
2199
- .catch((error) => {
2200
- if (mounted) {
2201
- setLoadError(error);
2202
- }
2203
- });
2204
- return () => {
2205
- mounted = false;
2206
- window.clearInterval(pollTimer);
2207
- };
2208
- }, []);
2209
-
2210
- React.useEffect(() => {
2211
- pollTimer = window.setInterval(pollOperations, 1400);
2212
- pollOperations();
2213
- return () => window.clearInterval(pollTimer);
2214
- }, []);
2215
-
2216
- React.useEffect(() => {
2217
- let mounted = true;
2218
- const loadGuide = () => {
2219
- fetchJson("/api/guide")
2220
- .then((result) => {
2221
- if (mounted && result && result.ok && result.currentStage) {
2222
- setGuide(result);
2223
- }
2224
- })
2225
- .catch(() => {});
2226
- };
2227
- loadGuide();
2228
- const timer = window.setInterval(loadGuide, 30000);
2229
- return () => {
2230
- mounted = false;
2231
- window.clearInterval(timer);
2232
- };
2233
- }, []);
2234
-
2235
- async function handleImportReviewDirections() {
2236
- if (isImportingDirections) {
2237
- return;
2238
- }
2239
- setIsImportingDirections(true);
2240
- try {
2241
- const result = await importReviewDirectionsToCanvas(t);
2242
- setActionStatus({ type: "success", message: formatMessage(t.reviewDirectionsImported, { count: result.count }) });
2243
- } catch (error) {
2244
- setActionStatus({
2245
- type: "error",
2246
- message: error?.code === "empty" ? t.reviewDirectionsEmpty : t.reviewDirectionsFailed,
2247
- });
2248
- } finally {
2249
- setIsImportingDirections(false);
2250
- }
2251
- }
2252
-
2253
- React.useEffect(() => {
2254
- hideNativeExcalidrawChrome();
2255
- const observer = new MutationObserver(hideNativeExcalidrawChrome);
2256
- const root = document.querySelector(".canvas-wrap");
2257
- if (root) {
2258
- observer.observe(root, { childList: true, subtree: true });
2259
- }
2260
- return () => observer.disconnect();
2261
- }, [initialData, locale]);
2262
-
2263
- React.useEffect(() => {
2264
- let cancelled = false;
2265
- async function buildPreviews() {
2266
- if (!initialData || !exportToCanvas || libraryItems.length === 0) {
2267
- setLibraryPreviews({});
2268
- return;
2269
- }
2270
- const previews = {};
2271
- for (const item of libraryItems.slice(0, 40)) {
2272
- if (cancelled || !Array.isArray(item.elements) || item.elements.length === 0) {
2273
- continue;
2274
- }
2275
- try {
2276
- const canvas = await exportToCanvas({
2277
- elements: item.elements,
2278
- appState: {
2279
- exportBackground: true,
2280
- viewBackgroundColor: "#ffffff",
2281
- },
2282
- files: currentFilesForFile(),
2283
- exportPadding: 12,
2284
- maxWidthOrHeight: 320,
2285
- });
2286
- previews[item.id] = canvas.toDataURL("image/png");
2287
- } catch (error) {
2288
- console.warn("OpenPrd canvas library preview failed", error);
2289
- }
2290
- }
2291
- if (!cancelled) {
2292
- setLibraryPreviews(previews);
2293
- }
2294
- }
2295
- buildPreviews();
2296
- return () => {
2297
- cancelled = true;
2298
- };
2299
- }, [initialData, libraryItems]);
2300
-
2301
- function changeLocale(event) {
2302
- setLocale(normalizeLocale(event.target.value));
2303
- }
2304
-
2305
- async function copyThreadPrompt() {
2306
- const copied = await copyTextToClipboard(buildThreadPrompt(t));
2307
- setActionStatus({
2308
- type: copied ? "success" : "error",
2309
- message: copied ? t.threadPromptCopied : t.threadPromptCopyFailed,
2310
- });
2311
- }
2312
-
2313
- function resetCanvas() {
2314
- if (!window.confirm(t.resetConfirm)) {
2315
- return;
2316
- }
2317
- latestElements = [];
2318
- latestFiles = {};
2319
- latestAppState = normalizeCanvasAppState({
2320
- ...latestAppState,
2321
- selectedElementIds: {},
2322
- });
2323
- excalidrawAPI?.updateScene?.({ elements: [], appState: latestAppState });
2324
- setSelectedCount(0);
2325
- setSelectedImageCount(0);
2326
- setActionStatus({ type: "success", message: t.resetCanvas });
2327
- queueSave();
2328
- }
2329
-
2330
- async function persistLibraryItems(items, intent = "sync-excalidraw-library") {
2331
- return fetchJson("/api/library", {
2332
- method: "POST",
2333
- body: JSON.stringify({
2334
- type: "excalidrawlib",
2335
- version: 2,
2336
- source: "https://openprd.local/canvas",
2337
- libraryItems: items,
2338
- intent,
2339
- locale,
2340
- }),
2341
- });
2342
- }
2343
-
2344
- function handleLibraryChange(items) {
2345
- const nextItems = Array.isArray(items) ? items : [];
2346
- setLibraryItems(nextItems);
2347
- persistLibraryItems(nextItems).catch((error) => {
2348
- console.warn("OpenPrd canvas library save failed", error);
2349
- });
2350
- }
2351
-
2352
- async function openContainingFolder(target) {
2353
- const downloadFileName = target?.downloadFileName || "";
2354
- const targetPath = target?.openPath || "";
2355
- if ((!downloadFileName && !targetPath) || isOpeningFolder) {
2356
- return;
2357
- }
2358
- setIsOpeningFolder(true);
2359
- try {
2360
- await fetchJson(downloadFileName ? "/api/open-download" : "/api/open-folder", {
2361
- method: "POST",
2362
- body: JSON.stringify(downloadFileName ? { filename: downloadFileName } : { path: targetPath }),
2363
- });
2364
- } catch (error) {
2365
- console.warn("OpenPrd canvas open folder failed", error);
2366
- setActionStatus((current) => ({
2367
- type: "error",
2368
- message: t.openFolderFailed,
2369
- openPath: current?.openPath || targetPath,
2370
- downloadFileName: current?.downloadFileName || downloadFileName,
2371
- }));
2372
- } finally {
2373
- setIsOpeningFolder(false);
2374
- }
2375
- }
2376
-
2377
- async function handleSaveCanvas() {
2378
- setActionStatus({ type: "busy", message: t.savingCanvas });
2379
- try {
2380
- const result = await saveCanvasToDisk();
2381
- setActionStatus({
2382
- type: "success",
2383
- message: formatMessage(t.savedCanvas, { filename: result.filename }),
2384
- downloadFileName: result.filename,
2385
- });
2386
- } catch (error) {
2387
- console.warn("OpenPrd canvas save-as failed", error);
2388
- setActionStatus({ type: "error", message: t.saveFailed });
2389
- }
2390
- }
2391
-
2392
- async function handleExportImage() {
2393
- if (isExporting) {
2394
- return;
2395
- }
2396
- setIsExporting(true);
2397
- setActionStatus({ type: "busy", message: t.exportingImage });
2398
- try {
2399
- const exportData = await exportCanvasImage(locale);
2400
- const path = exportData.exportPath || exportData.fileName || exportData.exportUrl || "";
2401
- setActionStatus({
2402
- type: "success",
2403
- message: formatMessage(t.exportedImage, { path }),
2404
- openPath: exportData.exportPath || "",
2405
- });
2406
- } catch (error) {
2407
- console.warn("OpenPrd canvas image export failed", error);
2408
- setActionStatus({ type: "error", message: error?.code === "empty-scene" ? t.emptySceneExportBody : t.exportFailed });
2409
- } finally {
2410
- setIsExporting(false);
2411
- }
2412
- }
2413
-
2414
- async function downloadSelectedImage() {
2415
- if (isDownloadingSelection || selectedImageCount <= 0) {
2416
- return;
2417
- }
2418
- setIsDownloadingSelection(true);
2419
- setActionStatus({ type: "busy", message: t.downloadingSelectedImage });
2420
- try {
2421
- const filename = createExportFilename("openprd-selected-image");
2422
- const exported = await exportCanvasElements({
2423
- requireSelection: true,
2424
- intent: "download-selected-image",
2425
- locale,
2426
- filename,
2427
- });
2428
- const exportData = exported.result.export || {};
2429
- const result = await fetchJson("/api/download-image", {
2430
- method: "POST",
2431
- body: JSON.stringify({
2432
- path: exportData.exportPath,
2433
- filename: exportData.fileName || filename,
2434
- }),
2435
- });
2436
- const fileName = result.file?.fileName || exportData.fileName || filename;
2437
- setActionStatus({
2438
- type: "success",
2439
- message: formatMessage(t.downloadedSelectedImage, { filename: fileName }),
2440
- downloadFileName: fileName,
2441
- });
2442
- } catch (error) {
2443
- console.warn("OpenPrd canvas selected image download failed", error);
2444
- setActionStatus({ type: "error", message: t.downloadSelectedImageFailed });
2445
- } finally {
2446
- setIsDownloadingSelection(false);
2447
- }
2448
- }
2449
-
2450
- function libraryItemName(item, index) {
2451
- return item?.name || formatMessage(t.libraryItemName, {
2452
- index: index + 1,
2453
- count: Array.isArray(item?.elements) ? item.elements.length : 0,
2454
- });
2455
- }
2456
-
2457
- function insertLibraryItem(item) {
2458
- if (!item || !Array.isArray(item.elements) || item.elements.length === 0) {
2459
- return;
2460
- }
2461
- const bounds = boundsForElements(item.elements);
2462
- const point = getViewportDropPoint(bounds.width, bounds.height);
2463
- const offsetX = point.x - bounds.x;
2464
- const offsetY = point.y - bounds.y;
2465
- const cloned = item.elements.map((element) => cloneLibraryElement(element, offsetX, offsetY));
2466
- updateScene([...latestElements, ...cloned]);
2467
- setActionStatus({ type: "success", message: t.insertedLibraryItem });
2468
- }
2469
-
2470
- function updateComposerInstruction(event) {
2471
- const value = event.target.value;
2472
- setComposer((current) => current ? { ...current, instruction: value, status: current.status === "sent" ? "ready" : current.status, promptText: "" } : current);
2473
- }
2474
-
2475
- async function prepareSelectionForCodex() {
2476
- if (isSending) {
2477
- return;
2478
- }
2479
- setIsSending(true);
2480
- setComposer(null);
2481
- try {
2482
- const selected = currentSelectedElements();
2483
- if (selected.length === 0) {
2484
- setComposer({
2485
- status: "error",
2486
- title: t.emptySelectionTitle,
2487
- body: t.emptySelectionBody,
2488
- });
2489
- return;
2490
- }
2491
- const filename = createExportFilename("codex-selection");
2492
- const exported = await exportCanvasElements({
2493
- requireSelection: true,
2494
- intent: "codex-handoff",
2495
- locale,
2496
- filename,
2497
- });
2498
- const exportPath = exported.result.export.exportPath;
2499
- setComposer({
2500
- status: "ready",
2501
- title: t.composerTitle,
2502
- body: "",
2503
- exported,
2504
- exportPath,
2505
- exportUrl: exported.result.export.exportUrl,
2506
- elementCount: exported.elements.length,
2507
- instruction: "",
2508
- promptText: "",
2509
- });
2510
- } catch (error) {
2511
- setComposer({
2512
- status: "error",
2513
- title: t.sendErrorTitle,
2514
- body: error.message || String(error),
2515
- });
2516
- } finally {
2517
- setIsSending(false);
2518
- }
2519
- }
2520
-
2521
- async function sendComposerToCodex() {
2522
- if (!composer?.exported || isSending) {
2523
- return;
2524
- }
2525
- setIsSending(true);
2526
- setComposer((current) => current ? { ...current, status: "sending", error: "" } : current);
2527
- try {
2528
- const instruction = String(composer.instruction || "").trim();
2529
- const promptText = buildCodexPrompt(t, composer.exportPath, composer.elementCount, instruction);
2530
- let relayTimedOut = false;
2531
- const relay = await withTimeout(recordCodexHandoff(composer.exported, promptText, locale, instruction), codexRelayUiTimeoutMs).catch((error) => {
2532
- relayTimedOut = error?.message === "timeout";
2533
- console.warn("OpenPrd canvas handoff record failed", error);
2534
- return null;
2535
- });
2536
- const copied = await withTimeout(copyTextToClipboard(promptText), 1800).catch(() => false);
2537
- const relayed = relayWasAccepted(relay);
2538
- const agentBridgePending = agentBridgeIsPending(relay);
2539
- const relayPending = relayIsPending(relay, relayTimedOut);
2540
- setComposer((current) => current ? {
2541
- ...current,
2542
- status: "sent",
2543
- copied,
2544
- body: agentBridgePending ? t.sentBodyAgentBridge : (relayed ? t.sentBodyWithRelay : (relayPending ? t.sentBodyPending : (copied ? t.sentBody : t.sendManualCopyBody))),
2545
- promptText,
2546
- } : current);
2547
- } catch (error) {
2548
- setComposer((current) => current ? {
2549
- ...current,
2550
- status: "ready",
2551
- error: error.message || String(error),
2552
- } : current);
2553
- } finally {
2554
- setIsSending(false);
2555
- }
2556
- }
2557
-
2558
- async function copyCurrentPrompt() {
2559
- if (composer?.promptText) {
2560
- await copyTextToClipboard(composer.promptText);
2561
- }
2562
- }
2563
-
2564
- function openImageTemplate(template) {
2565
- setImageComposer({
2566
- status: "ready",
2567
- template,
2568
- instruction: "",
2569
- promptText: "",
2570
- error: "",
2571
- });
2572
- }
2573
-
2574
- function updateImageComposerInstruction(event) {
2575
- const value = event.target.value;
2576
- setImageComposer((current) => current ? {
2577
- ...current,
2578
- instruction: value,
2579
- status: current.status === "sent" ? "ready" : current.status,
2580
- error: "",
2581
- } : current);
2582
- }
2583
-
2584
- async function submitImageTemplateRequest() {
2585
- if (!imageComposer?.template || isRequestingImage) {
2586
- return;
2587
- }
2588
- const instruction = String(imageComposer.instruction || "").trim();
2589
- if (!instruction) {
2590
- setImageComposer((current) => current ? { ...current, error: t.imagePromptRequired } : current);
2591
- return;
2592
- }
2593
- setIsRequestingImage(true);
2594
- setImageComposer((current) => current ? { ...current, status: "sending", error: "" } : current);
2595
- try {
2596
- const created = createAiImageCard(imageComposer.template, instruction, t);
2597
- const promptText = buildImageRequestPrompt(t, imageComposer.template, instruction, created.placeholderId);
2598
- attachAiImagePrompt(created.placeholderId, promptText);
2599
- let relayTimedOut = false;
2600
- const relay = await withTimeout(recordCodexImageRequestHandoff(promptText, locale, instruction, imageComposer.template, created), codexRelayUiTimeoutMs).catch((error) => {
2601
- relayTimedOut = error?.message === "timeout";
2602
- console.warn("OpenPrd canvas image handoff record failed", error);
2603
- return null;
2604
- });
2605
- const copied = await withTimeout(copyTextToClipboard(promptText), 1800).catch(() => false);
2606
- const relayed = relayWasAccepted(relay);
2607
- const agentBridgePending = agentBridgeIsPending(relay);
2608
- const relayPending = relayIsPending(relay, relayTimedOut);
2609
- setActionStatus({
2610
- type: relayed ? "success" : (agentBridgePending || relayPending ? "busy" : "success"),
2611
- message: agentBridgePending ? t.imageRequestAgentBridge : (relayed ? t.imageRequestSent : (relayPending ? t.imageRequestPending : (copied ? t.imageRequestPrepared : t.imageRequestManualCopy))),
2612
- });
2613
- setImageComposer(null);
2614
- } catch (error) {
2615
- console.warn("OpenPrd canvas image request failed", error);
2616
- setImageComposer((current) => current ? {
2617
- ...current,
2618
- status: "ready",
2619
- error: error.message || String(error),
2620
- } : current);
2621
- } finally {
2622
- setIsRequestingImage(false);
2623
- }
2624
- }
2625
-
2626
- if (loadError) {
2627
- return h("div", { className: "fallback" },
2628
- h("div", null,
2629
- h("strong", null, t.loadErrorTitle),
2630
- h("span", null, loadError.message || String(loadError)),
2631
- ),
2632
- );
2633
- }
2634
-
2635
- if (!initialData) {
2636
- return h("div", { className: "fallback" },
2637
- h("div", null,
2638
- h("strong", null, t.loadingTitle),
2639
- h("span", null, t.loadingBody),
2640
- ),
2641
- );
2642
- }
2643
-
2644
- return h("div", { className: "shell" },
2645
- h("header", { className: "topbar" },
2646
- h("div", { className: "brand" },
2647
- h("strong", null, "OpenPrd Canvas"),
2648
- h("span", null, t.appSubtitle),
2649
- ),
2650
- h("div", { className: "actions" },
2651
- h("select", {
2652
- className: "language-select",
2653
- "aria-label": t.languageLabel,
2654
- "data-testid": "openprd-canvas-locale",
2655
- value: locale,
2656
- onChange: changeLocale,
2657
- },
2658
- h("option", { value: "zh-CN" }, "中文"),
2659
- h("option", { value: "en" }, "English"),
2660
- ),
2661
- ),
2662
- ),
2663
- h("main", { className: "layout" },
2664
- h("section", { className: "canvas-wrap" },
2665
- h(Excalidraw, {
2666
- excalidrawAPI: (api) => {
2667
- excalidrawAPI = api;
2668
- window.__openprdExcalidrawAPI = api;
2669
- },
2670
- initialData,
2671
- name: "OpenPrd Canvas",
2672
- langCode: locale,
2673
- UIOptions: {
2674
- canvasActions: {
2675
- loadScene: false,
2676
- saveToActiveFile: false,
2677
- export: {
2678
- saveFileToDisk: true,
2679
- },
2680
- },
2681
- },
2682
- onChange: (elements, appState, files) => {
2683
- latestElements = elements || [];
2684
- latestAppState = normalizeCanvasAppState(appState || {});
2685
- latestFiles = files || {};
2686
- setSelectedCount(selectedCountFromAppState(latestAppState));
2687
- setSelectedImageCount(selectedImageCountFromAppState(latestAppState, latestElements));
2688
- queueSave();
2689
- },
2690
- onLibraryChange: handleLibraryChange,
2691
- }),
2692
- selectedCount > 0 && h("div", { className: "selection-sendbar", "data-testid": "openprd-canvas-selection-sendbar" },
2693
- h("strong", null, formatMessage(t.selectedCount, { count: selectedCount })),
2694
- selectedImageCount > 0 && h("button", {
2695
- className: "text-button subtle-button",
2696
- disabled: isDownloadingSelection || isSending,
2697
- onClick: downloadSelectedImage,
2698
- "data-testid": "openprd-canvas-download-image",
2699
- }, toolIcon("export"), h("span", null, isDownloadingSelection ? t.downloadingSelectedImage : t.downloadSelectedImage)),
2700
- h("button", {
2701
- className: "text-button primary-button",
2702
- disabled: isSending,
2703
- onClick: prepareSelectionForCodex,
2704
- "data-testid": "openprd-canvas-send-codex",
2705
- }, toolIcon("send"), h("span", null, isSending ? t.sendingToCodex : t.sendToCodex)),
2706
- ),
2707
- composer && h("div", { className: "composer-backdrop", "data-testid": "openprd-canvas-composer" },
2708
- h("section", { className: "composer-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "openprd-canvas-composer-title" },
2709
- h("div", { className: "composer-header" },
2710
- h("h2", { id: "openprd-canvas-composer-title" }, composer.title || t.composerTitle),
2711
- h("button", { className: "text-button", onClick: () => setComposer(null) }, h("span", null, t.dismiss)),
2712
- ),
2713
- h("div", { className: "composer-body" },
2714
- composer.exportUrl && h(React.Fragment, null,
2715
- h("p", { className: "composer-label" }, t.previewLabel),
2716
- h("img", { className: "composer-preview", src: composer.exportUrl, alt: t.composerPreviewAlt }),
2717
- ),
2718
- composer.exported && h(React.Fragment, null,
2719
- h("p", { className: "composer-label" }, t.instructionLabel),
2720
- h("textarea", {
2721
- className: "composer-input",
2722
- value: composer.instruction || "",
2723
- placeholder: t.instructionPlaceholder,
2724
- disabled: isSending || composer.status === "sent",
2725
- onChange: updateComposerInstruction,
2726
- "data-testid": "openprd-canvas-composer-prompt",
2727
- }),
2728
- ),
2729
- composer.body && h("p", { className: "composer-status" }, composer.body),
2730
- composer.error && h("p", { className: "composer-error" }, composer.error),
2731
- ),
2732
- h("div", { className: "composer-actions" },
2733
- composer.promptText && h("button", { className: "text-button", onClick: copyCurrentPrompt }, toolIcon("copy"), h("span", null, t.copyPrompt)),
2734
- composer.exported && h("button", {
2735
- className: "text-button primary-button",
2736
- disabled: isSending || composer.status === "sent",
2737
- onClick: sendComposerToCodex,
2738
- "data-testid": "openprd-canvas-composer-submit",
2739
- }, toolIcon("send"), h("span", null, isSending || composer.status === "sending" ? t.sendingToCodex : t.sendToCodex)),
2740
- ),
2741
- ),
2742
- ),
2743
- imageComposer && h("div", { className: "composer-backdrop", "data-testid": "openprd-canvas-image-composer" },
2744
- h("section", { className: "composer-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": "openprd-canvas-image-composer-title" },
2745
- h("div", { className: "composer-header" },
2746
- h("h2", { id: "openprd-canvas-image-composer-title" }, t.aiImageComposerTitle),
2747
- h("button", { className: "text-button", onClick: () => setImageComposer(null), disabled: isRequestingImage }, h("span", null, t.dismiss)),
2748
- ),
2749
- h("div", { className: "composer-body" },
2750
- h("p", { className: "composer-label" }, t.aiImageTemplateLabel),
2751
- h("div", { className: "template-compose-preview" },
2752
- h("div", {
2753
- className: "template-compose-frame",
2754
- style: { aspectRatio: imageComposer.template.width + " / " + imageComposer.template.height },
2755
- }, h("span", null, imageComposer.template.label)),
2756
- ),
2757
- h("p", { className: "composer-label" }, t.aiImageInstructionLabel),
2758
- h("textarea", {
2759
- className: "composer-input",
2760
- value: imageComposer.instruction || "",
2761
- placeholder: t.aiImageInstructionPlaceholder,
2762
- disabled: isRequestingImage,
2763
- onChange: updateImageComposerInstruction,
2764
- "data-testid": "openprd-canvas-image-prompt",
2765
- }),
2766
- imageComposer.error && h("p", { className: "composer-error" }, imageComposer.error),
2767
- ),
2768
- h("div", { className: "composer-actions" },
2769
- h("button", { className: "text-button", onClick: () => setImageComposer(null), disabled: isRequestingImage }, h("span", null, t.dismiss)),
2770
- h("button", {
2771
- className: "text-button primary-button",
2772
- disabled: isRequestingImage,
2773
- onClick: submitImageTemplateRequest,
2774
- "data-testid": "openprd-canvas-create-ai-image",
2775
- }, toolIcon("send"), h("span", null, isRequestingImage ? t.sendingToCodex : t.createAiImageCard)),
2776
- ),
2777
- ),
2778
- ),
2779
- ),
2780
- h("aside", { className: "rail" },
2781
- h("section", { className: "thread-summary", "data-testid": "openprd-canvas-thread-summary" },
2782
- h("p", { className: "thread-title" }, conversationTitle(t)),
2783
- h("div", { className: "thread-id-row" },
2784
- h("p", { className: "thread-id" }, conversationId()),
2785
- h("button", {
2786
- className: "thread-copy-button",
2787
- type: "button",
2788
- title: t.copyThreadPrompt,
2789
- "aria-label": t.copyThreadPrompt,
2790
- onClick: copyThreadPrompt,
2791
- "data-testid": "openprd-canvas-copy-thread",
2792
- }, toolIcon("copy")),
2793
- ),
2794
- ),
2795
- h("section", { className: "rail-actions", "data-testid": "openprd-canvas-rail-actions" },
2796
- h("div", { className: "rail-action-group", "data-testid": "openprd-canvas-review-board" },
2797
- h("p", { className: "rail-heading" }, t.reviewBoardTitle),
2798
- guide && h("div", { className: "guide-strip", "data-testid": "openprd-canvas-guide" },
2799
- h("strong", null, formatMessage(t.reviewGuideStage, {
2800
- index: guide.currentStage.index,
2801
- total: guide.totalStages,
2802
- title: guide.currentStage.title,
2803
- })),
2804
- guide.nextAction && h("span", null, formatMessage(t.reviewGuideNext, { action: guide.nextAction })),
2805
- ),
2806
- h("button", {
2807
- className: "rail-action-button",
2808
- title: t.importReviewDirections,
2809
- disabled: isImportingDirections,
2810
- onClick: handleImportReviewDirections,
2811
- "data-testid": "openprd-canvas-import-directions",
2812
- }, toolIcon("export"), h("span", null, isImportingDirections ? t.importingReviewDirections : t.importReviewDirections)),
2813
- ),
2814
- h("div", { className: "rail-action-group" },
2815
- h("p", { className: "rail-heading" }, t.canvasActionsTitle),
2816
- h("div", { className: "rail-action-grid" },
2817
- h("button", {
2818
- className: "rail-action-button",
2819
- title: t.saveCanvas,
2820
- onClick: handleSaveCanvas,
2821
- "data-testid": "openprd-canvas-save",
2822
- }, toolIcon("save"), h("span", null, t.saveCanvas)),
2823
- h("button", {
2824
- className: "rail-action-button",
2825
- title: t.exportImage,
2826
- disabled: isExporting,
2827
- onClick: handleExportImage,
2828
- "data-testid": "openprd-canvas-export-image",
2829
- }, toolIcon("export"), h("span", null, isExporting ? t.exportingImage : t.exportImage)),
2830
- h("button", {
2831
- className: "rail-action-button danger",
2832
- title: t.resetCanvas,
2833
- onClick: resetCanvas,
2834
- "data-testid": "openprd-canvas-reset",
2835
- }, toolIcon("reset"), h("span", null, t.resetCanvas)),
2836
- ),
2837
- actionStatus && h("div", {
2838
- className: "action-status" + (actionStatus.type === "error" ? " error" : "") + (actionStatus.type === "busy" ? " busy" : ""),
2839
- "data-testid": "openprd-canvas-action-status",
2840
- },
2841
- h("span", null, actionStatus.message),
2842
- (actionStatus.openPath || actionStatus.downloadFileName) && h("button", {
2843
- className: "status-folder-button",
2844
- disabled: isOpeningFolder,
2845
- onClick: () => openContainingFolder(actionStatus),
2846
- "data-testid": "openprd-canvas-open-folder",
2847
- }, isOpeningFolder ? t.openingFolder : t.openContainingFolder),
2848
- ),
2849
- ),
2850
- h("div", { className: "rail-action-group" },
2851
- h("p", { className: "rail-heading" }, t.aiImageTemplatesTitle),
2852
- h("div", { className: "template-grid", "data-testid": "openprd-canvas-ai-templates" },
2853
- aiTemplates.map((template) => h("button", {
2854
- key: template.id,
2855
- className: "template-tile",
2856
- title: template.title + " " + template.label,
2857
- onClick: () => openImageTemplate(template),
2858
- "data-testid": "openprd-canvas-ai-template-" + template.id,
2859
- },
2860
- h("span", {
2861
- className: "template-preview",
2862
- style: { aspectRatio: template.width + " / " + template.height },
2863
- "aria-hidden": "true",
2864
- }),
2865
- h("strong", null, template.label),
2866
- h("span", null, template.title),
2867
- )),
2868
- ),
2869
- ),
2870
- h("div", { className: "rail-action-group" },
2871
- h("p", { className: "rail-heading" }, t.personalLibraryTitle),
2872
- h("div", { className: "library-grid", "data-testid": "openprd-canvas-personal-library" },
2873
- libraryItems.map((item, index) => h("button", {
2874
- key: item.id || index,
2875
- className: "library-tile",
2876
- title: libraryItemName(item, index),
2877
- onClick: () => insertLibraryItem(item),
2878
- "data-testid": "openprd-canvas-library-item",
2879
- },
2880
- h("span", { className: "library-item-preview", "aria-hidden": "true" },
2881
- libraryPreviews[item.id]
2882
- ? h("img", { src: libraryPreviews[item.id], alt: "" })
2883
- : h("span", null, formatMessage(t.libraryItemCount, { count: Array.isArray(item.elements) ? item.elements.length : 0 })),
2884
- ),
2885
- h("span", null, libraryItemName(item, index)),
2886
- )),
2887
- h("button", {
2888
- className: "library-tile",
2889
- title: t.blankCard,
2890
- onClick: () => addBlankCard(t.placeholderText),
2891
- "data-testid": "openprd-canvas-blank-card",
2892
- },
2893
- h("span", { className: "library-card-preview", "aria-hidden": "true" }),
2894
- h("span", null, t.blankCard),
2895
- ),
2896
- ),
2897
- ),
2898
- ),
2899
- ),
2900
- ),
2901
- );
2902
- }
2903
-
2904
- createRoot(document.getElementById("root")).render(h(CanvasApp));
128
+ ${CANVAS_APP_CLIENT_HELPERS}
129
+ ${CANVAS_APP_CLIENT_APP}
2905
130
  </script>
2906
131
  </body>
2907
132
  </html>`;