cardbuddy 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.js +14 -6
  3. package/dist/config.js +13 -2
  4. package/dist/preview-editor/assets/index-B4VZgNSK.css +1 -0
  5. package/dist/preview-editor/assets/index-jl4YJzKv.js +180 -0
  6. package/dist/preview-editor/index.html +2 -2
  7. package/dist/server-core/_vendor/finmall-env.mjs +4 -4
  8. package/dist/server-core/ai-chat.js +3710 -0
  9. package/dist/server-core/cardFiles.js +95 -2
  10. package/dist/server-core/cardImage.js +14 -0
  11. package/dist/server-core/cardSave.js +2 -0
  12. package/dist/server-core/cardValidate.js +3 -0
  13. package/dist/server-core/index.js +3 -0
  14. package/dist/server-core/sliceUpload.js +8 -1
  15. package/dist/vendored/App.vue +269 -79
  16. package/dist/vendored/api.js +4 -4
  17. package/dist/vendored/assets/svgIcons/icons.svg +28 -0
  18. package/dist/vendored/components/AiSettings.vue +392 -0
  19. package/dist/vendored/components/CardGallery.vue +51 -5
  20. package/dist/vendored/components/ChatComposer.vue +413 -0
  21. package/dist/vendored/components/ChatMessage.vue +383 -0
  22. package/dist/vendored/components/ChatPanel.vue +937 -0
  23. package/dist/vendored/components/DebugPanel.vue +30 -33
  24. package/dist/vendored/components/Dropdown.vue +4 -2
  25. package/dist/vendored/components/MarkdownBody.vue +64 -0
  26. package/dist/vendored/components/NewCardDialog.vue +132 -0
  27. package/dist/vendored/components/RemotePreview.vue +1 -1
  28. package/dist/vendored/main.js +1 -0
  29. package/dist/vendored/runtime/adapter.js +1 -1
  30. package/dist/vendored/runtime/ai-api.js +165 -0
  31. package/dist/vendored/runtime/markdown.js +140 -0
  32. package/dist/vendored/skills/.manifest.json +1101 -0
  33. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +249 -0
  34. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +678 -0
  35. package/dist/vendored/skills/dsl-card-generator/scripts/derive_data_json.py +81 -0
  36. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +105 -0
  37. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +624 -0
  38. package/dist/vendored/skills/finmall-card-sync/scripts/api_batch_sync.mjs +900 -0
  39. package/dist/vendored/skills/finmall-card-sync/scripts/slice_upload.mjs +699 -0
  40. package/dist/vendored/skills/shared/finmall-config.mjs +124 -0
  41. package/dist/vendored/styles/ai-chat.css +1850 -0
  42. package/dist/vendored/styles/app.css +107 -3
  43. package/dist/vendored/styles/card-gallery.css +45 -7
  44. package/dist/vendored/styles/node-inspector.css +11 -0
  45. package/package.json +1 -1
  46. package/dist/preview-editor/assets/index-C_UKS0NF.css +0 -1
  47. package/dist/preview-editor/assets/index-D1iIJ4sl.js +0 -81
@@ -3,7 +3,7 @@
3
3
  右侧调试面板。
4
4
  -->
5
5
  <template>
6
- <div class="app" :class="{ 'app--chrome-off': chromeOff }" :style="{ '--preview-width': previewWidth + 'px', '--api-panel-width': apiPanelWidth + 'px' }">
6
+ <div class="app" :class="{ 'app--chrome-off': chromeOff }" :style="{ '--preview-width': previewWidth + 'px', '--api-panel-width': apiPanelWidth + 'px', '--debug-min': debugMinWidth + 'px' }">
7
7
  <header class="app__bar" v-show="!chromeOff">
8
8
  <strong class="app__logo">
9
9
  <img class="app__logo-icon" :src="logoUrl" alt="" />
@@ -35,6 +35,15 @@
35
35
  >
36
36
  <svg class="app__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-grid" /></svg>
37
37
  </button>
38
+ <!-- 新增空白卡片:紧邻网格预览的图标按钮;弹窗收 ID(必填、本地重名前置拦截)+ 描述(选填),创建成功切换到新卡 -->
39
+ <button
40
+ class="app__btn app__btn--icon"
41
+ type="button"
42
+ @click="openNewCard"
43
+ title="新增空白卡片"
44
+ >
45
+ <svg class="app__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-folder-plus" /></svg>
46
+ </button>
38
47
  <div
39
48
  ref="validateBadgeEl"
40
49
  class="app__badge"
@@ -233,10 +242,30 @@
233
242
  </Transition>
234
243
  </div>
235
244
  </div>
236
- <!-- revision 依赖锚点:setData 自增后驱动本组件重渲染 -->
237
245
  <span style="display: none">{{ rt.revision }}</span>
238
246
  </header>
239
247
 
248
+ <!-- AI 开发助手悬浮入口:右下角圆形按钮(尺寸 / 配色走规范 Token,图标取自 sprite) -->
249
+ <button
250
+ class="ai-fab"
251
+ type="button"
252
+ :class="{ 'ai-fab--active': aiChatOpen }"
253
+ :title="aiChatOpen ? 'AI 开发助手(已打开)' : 'AI 开发助手'"
254
+ :aria-expanded="aiChatOpen ? 'true' : 'false'"
255
+ data-testid="ai-fab"
256
+ @click="aiChatOpen = !aiChatOpen"
257
+ >
258
+ <svg class="ai-fab__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><use href="#icon-sparkles" /></svg>
259
+ </button>
260
+
261
+ <!-- AI 开发助手对话面板:右侧抽屉,宽度可拖(320px ~ 视口 50%,默认 33%),对话记录按卡片落盘 -->
262
+ <ChatPanel
263
+ :visible="aiChatOpen"
264
+ :card-id="selectedId"
265
+ :card-name="selectedCardName"
266
+ @close="aiChatOpen = false"
267
+ />
268
+
240
269
  <!-- 部署态但未配置本地开发服务:顶部提示条(chrome=off 截图模式不显示) -->
241
270
  <div v-if="apiBaseResolved === null && !chromeOff" class="app__nosvc-banner">
242
271
  <template v-if="devSvcBlockReason === 'disabled'">
@@ -358,7 +387,7 @@
358
387
  v-show="!chromeOff"
359
388
  class="splitter"
360
389
  :class="{ 'is-dragging': dragging }"
361
- @mousedown.prevent="startDrag"
390
+ @pointerdown.prevent="startDrag"
362
391
  title="拖拽调整预览区域宽度"
363
392
  ></div>
364
393
 
@@ -369,7 +398,7 @@
369
398
  <div class="workside" v-show="!chromeOff">
370
399
  <div id="app-workside-bar" class="workside__bar"></div>
371
400
  <div id="app-workside-panels" class="workside__panels"></div>
372
- <div class="workside__cols">
401
+ <div class="workside__cols" ref="colsEl">
373
402
  <!-- 拖拽分隔条:置于两栏容器内(定位上下文),只贯穿列区高度,不穿过底部弹出面板与状态栏 -->
374
403
  <!-- 中右分隔条:调试面板 | 接口调用面板 接缝,拖动调整右栏宽度。
375
404
  约束:中栏最小宽度 ≥ 屏宽 1/3,右栏最小宽度 ≥ 320px(见 startApiDrag) -->
@@ -377,7 +406,7 @@
377
406
  v-show="!chromeOff && apiPanelOpen"
378
407
  class="splitter splitter--api"
379
408
  :class="{ 'is-dragging': apiDragging }"
380
- @mousedown.prevent="startApiDrag"
409
+ @pointerdown.prevent="startApiDrag"
381
410
  title="拖拽调整接口调用面板宽度"
382
411
  ></div>
383
412
  <DebugPanel
@@ -504,6 +533,14 @@
504
533
  :selected-id="selectedId"
505
534
  @select="onSelect"
506
535
  @close="closeGallery"
536
+ @delete="onGalleryDelete"
537
+ />
538
+ <!-- 新增空白卡片弹窗:创建成功后关弹窗 + 刷列表 + 切换到新卡;existing-ids 供 ID 重名前置判定 -->
539
+ <NewCardDialog
540
+ :visible="newCardOpen"
541
+ :existing-ids="cardIds"
542
+ @close="newCardOpen = false"
543
+ @created="onNewCardCreated"
507
544
  />
508
545
  <!-- App 级弱提示:顶栏异步操作(远端保存 / 同步)完成反馈,浮于 App 外壳顶部居中 -->
509
546
  <AppToast />
@@ -537,8 +574,10 @@ import RemotePreview from '@/components/RemotePreview.vue'
537
574
  import AppToast from '@/components/AppToast.vue'
538
575
  import ConfirmDialog from '@/components/ConfirmDialog.vue'
539
576
  import CardGallery from '@/components/CardGallery.vue'
577
+ import NewCardDialog from '@/components/NewCardDialog.vue'
540
578
  import ComponentPalette from '@/components/ComponentPalette.vue'
541
579
  import Switch from '@/components/Switch.vue'
580
+ import ChatPanel from '@/components/ChatPanel.vue'
542
581
  import { showAppToast } from '@/runtime/app-toast.js'
543
582
  import { clearAllAlerts } from '@/runtime/alert-card.js'
544
583
  import { installJsApiMock, clearDeviceUI } from '@/runtime/jsapi-mock.js'
@@ -563,9 +602,16 @@ const cardRuntime = createCardRuntime({}, adapter)
563
602
  // 直接驱动 rt.setRootScope —— 与 DebugPanel「变量 → 配置变量 mock」Raw「应用」同源。生产构建不暴露。
564
603
  if (typeof window !== 'undefined' && import.meta.env.DEV) window.__rt = cardRuntime
565
604
 
605
+ // 三栏(预览列 | 调试面板 | 接口调用面板)布局约束的单一来源:
606
+ // 拖拽夹取与 computed 都从这里取值,避免「两套口径」在极限位置互相打架导致接缝跳动 / 整行溢出。
607
+ const PREVIEW_MIN = 188 // 预览列硬下限(= 最小机型预设宽)
608
+ const API_MIN = 320 // 接口调用面板常规下限(空间不足时按 apiPanelBounds 优雅退让,不整行溢出)
609
+ const DEBUG_MIN_FLOOR = 200 // 中栏(调试面板)硬下限;软目标为屏宽 1/3
610
+ const API_ABS_MIN = 160 // 极窄窗口下面板的绝对下限,保证不撑破容器
611
+
566
612
  export default {
567
613
  name: 'App',
568
- components: { RenderTree, DebugPanel, ApiCallPanel, CardSelect, AlertCardLayer, DeviceOverlay, PickerOverlay, DatePickerOverlay, SelectionOverlay, HoverGuides, RemotePreview, AppToast, ConfirmDialog, CardGallery, ComponentPalette, Switch },
614
+ components: { RenderTree, DebugPanel, ApiCallPanel, CardSelect, AlertCardLayer, DeviceOverlay, PickerOverlay, DatePickerOverlay, SelectionOverlay, HoverGuides, RemotePreview, AppToast, ConfirmDialog, CardGallery, NewCardDialog, ComponentPalette, Switch, ChatPanel },
569
615
  provide() {
570
616
  return {
571
617
  adapter,
@@ -583,7 +629,9 @@ export default {
583
629
  appVersion: __APP_VERSION__,
584
630
  cardList: [], // 实时从 GET /__cards 拉取,不缓存启动快照
585
631
  cardCount: 0,
586
- galleryOpen: false, // 卡片网格预览弹窗开关
632
+ galleryOpen: false,
633
+ // 新增空白卡片弹窗开关(顶栏图标按钮触发)
634
+ newCardOpen: false, // 卡片网格预览弹窗开关
587
635
  selectedId: '',
588
636
  // 外部(IDE / 其他工具)改了当前卡、且平台无未保存改动时,自动重载后顶部短暂提示
589
637
  // (非阻塞,区别于有未保存改动时的阻塞确认框)。
@@ -697,15 +745,22 @@ export default {
697
745
  { key: 188, value: '188' },
698
746
  ],
699
747
  stageWidth: typeof window !== 'undefined' ? window.innerWidth : 1280, // 舞台实际宽度(ResizeObserver 实测后更新)
748
+ colsWidth: 0, // 中右两栏容器(.workside__cols)实测宽度:右栏宽度夹取的单一口径
700
749
  stageHeight: typeof window !== 'undefined' ? window.innerHeight - 52 : 748, // 舞台实际高度(contain 缩放的“高度上限”基准)
701
750
  dragging: false, // 是否正在拖拽分隔条
702
751
  apiDragging: false, // 是否正在拖拽「接口调用」面板分隔条
703
752
  _stageObserver: null,
704
- _dragMove: null,
705
- _dragUp: null,
706
753
  // —— 本地开发服务(前后端分离)配置入口 ——
707
754
  // devServiceForm:设置面板双向绑定的草稿;启用后前端所有 /__xxx 请求改走该地址。
708
755
  devServiceOpen: false,
756
+ // AI 面板开合跨刷新保持:dev server 重启 / 误刷新后不必重新找入口
757
+ aiChatOpen: (() => {
758
+ try {
759
+ return sessionStorage.getItem('cardbuddy:ai-chat-open') === '1'
760
+ } catch {
761
+ return false
762
+ }
763
+ })(),
709
764
  devServiceForm: loadDevServiceConfig(),
710
765
  // apiBaseResolved:当前生效的 API 基址;null 表示部署态未配置(必须用本地服务才能编辑)。
711
766
  apiBaseResolved: resolveApiBase(),
@@ -725,6 +780,10 @@ export default {
725
780
  currentBg() {
726
781
  return this.bgOptions.find((b) => b.key === this.canvasBg) || this.bgOptions[0]
727
782
  },
783
+ /** 本地已有卡片 ID(GET /__cards 实时扫描 src/cards 的结果):新增空白卡片时做重名前置判定 */
784
+ cardIds() {
785
+ return this.cardList.map((c) => c && c.id).filter(Boolean)
786
+ },
728
787
  // 撤销可用:存在可回退的上一版本;重做可用:存在被撤销后的版本
729
788
  canUndo() {
730
789
  return this.past.length > 0
@@ -825,27 +884,58 @@ export default {
825
884
  return ''
826
885
  },
827
886
  // 预览列宽度(像素):chromeOff 时占满整屏(截图/真机模式,非“宽度调整”场景);
828
- // 否则使用 _previewWidthPx 并限制在 [188, 可视宽度/2],最大不得超过画布左侧可视区域的一半
887
+ // 否则使用 _previewWidthPx 并夹到 previewBounds(拖拽与渲染共用同一口径,杜绝两套夹取打架)
829
888
  previewWidth() {
830
889
  if (this.chromeOff) return this.stageWidth
831
- const total = this.stageWidth || 1
832
- const minW = 188 // 最小宽度不得小于 188(预设最小尺寸)
833
- const maxW = Math.max(minW, Math.floor(total / 2)) // 最大不得超过可视宽度的一半
834
- const w = this._previewWidthPx || minW
835
- return Math.max(minW, Math.min(maxW, w))
890
+ const { min, max } = this.previewBounds
891
+ const w = this._previewWidthPx || PREVIEW_MIN
892
+ return Math.max(min, Math.min(max, w))
893
+ },
894
+ /** 视口宽(响应式:随 stageWidth / colsWidth 的实测更新一起重算) */
895
+ viewportWidth() {
896
+ // 依赖 stageWidth 以便窗口缩放时重算;取值仍用 innerWidth(vw 口径与 CSS 一致)
897
+ void this.stageWidth
898
+ return typeof window !== 'undefined' ? window.innerWidth : 1280
899
+ },
900
+ /** 中栏软目标宽:屏宽 1/3,且不低于硬下限(下发给 CSS `.debug` 的 min-width) */
901
+ debugSoftWidth() {
902
+ return Math.max(DEBUG_MIN_FLOOR, Math.floor(this.viewportWidth / 3))
903
+ },
904
+ /** 预览列宽度区间:[188, min(舞台宽/2, 舞台宽 - 右栏下限 - 中栏软目标)]。
905
+ * 只依赖舞台宽与视口宽(不反向依赖中右两栏实测),杜绝循环夹取。 */
906
+ previewBounds() {
907
+ const stage = this.stageWidth || 0
908
+ const byHalf = Math.floor(stage / 2)
909
+ const byWorkspace = stage - API_MIN - this.debugSoftWidth
910
+ return { min: PREVIEW_MIN, max: Math.max(PREVIEW_MIN, Math.min(byHalf, byWorkspace)) }
911
+ },
912
+ /** 中栏(调试面板)实际最小宽:软目标(屏宽 1/3)优先,空间不足时退让到硬下限 */
913
+ debugMinWidth() {
914
+ const cols = this.colsWidth || Math.max(0, (this.stageWidth || 0) - this.previewWidth)
915
+ return Math.max(DEBUG_MIN_FLOOR, Math.min(this.debugSoftWidth, Math.floor(cols) - API_MIN))
916
+ },
917
+ /** 右栏(接口调用面板)宽度区间:常规 [320, 两栏容器宽 - 中栏最小宽];
918
+ * 容器实在放不下 320 时(窄窗口 + 预览列已占位)整体退让到剩余空间,绝不撑破容器
919
+ * —— 撑破会让面板被推出视口、分隔条接缝与面板边缘脱节,表现为「拖到极限就跳」。 */
920
+ apiPanelBounds() {
921
+ const cols = this.colsWidth || Math.max(0, (this.stageWidth || 0) - this.previewWidth)
922
+ const max = Math.max(API_ABS_MIN, Math.floor(cols) - this.debugMinWidth)
923
+ return { min: Math.min(API_MIN, max), max }
836
924
  },
837
925
  // 画布实际宽度(列宽,整数):canvas-bar__size 实时显示用,拖拽分隔条时随之变化
838
926
  canvasW() {
839
927
  return Math.round(this.previewWidth || 375)
840
928
  },
841
- // 右侧「接口调用」面板宽度(像素):限制在 [320, 舞台宽度 - 预览列宽 - 中栏最小宽(屏宽 1/3)],
842
- // 保证中栏(调试面板)最小宽度不小于三分之一屏幕宽度、右栏不小于 320px
929
+ /** 当前卡片名(AI 开发助手头部展示;列表未加载完时回落 id) */
930
+ selectedCardName() {
931
+ const hit = this.cardList.find((c) => c.id === this.selectedId)
932
+ return (hit && hit.name) || this.selectedId || ''
933
+ },
934
+ // 右侧「接口调用」面板宽度(像素):夹到 apiPanelBounds,保证中栏不小于屏宽 1/3、右栏不小于 320px
843
935
  apiPanelWidth() {
844
- const minW = 320 // 右栏最小宽度
845
- const midMin = Math.floor((typeof window !== 'undefined' ? window.innerWidth : 1280) / 3)
846
- const maxW = Math.max(minW, Math.floor((this.stageWidth || 0) - this.previewWidth - midMin))
936
+ const { min, max } = this.apiPanelBounds
847
937
  const w = this._apiPanelWidthPx || 360
848
- return Math.max(minW, Math.min(maxW, w))
938
+ return Math.max(min, Math.min(max, w))
849
939
  },
850
940
  // 缩放外框占满整列(宽高交由 CSS flex 填满;画布不再做 transform 缩放,直接按实际尺寸布局)
851
941
  frameStyle() {
@@ -861,6 +951,13 @@ export default {
861
951
  clearHoverEl()
862
952
  }
863
953
  },
954
+ aiChatOpen(val) {
955
+ try {
956
+ sessionStorage.setItem('cardbuddy:ai-chat-open', val ? '1' : '0')
957
+ } catch {
958
+ /* sessionStorage 不可用时忽略 */
959
+ }
960
+ },
864
961
  // 主题切换:把 'dark' / 'light' 写到 <html data-theme>,驱动 CSS 变量整体换肤
865
962
  theme(val) {
866
963
  document.documentElement.dataset.theme = val
@@ -877,8 +974,6 @@ export default {
877
974
  beforeUnmount() {
878
975
  if (this.viewportObserver) this.viewportObserver.disconnect()
879
976
  if (this._stageObserver) this._stageObserver.disconnect()
880
- if (this._dragMove) document.removeEventListener('mousemove', this._dragMove)
881
- if (this._dragUp) document.removeEventListener('mouseup', this._dragUp)
882
977
  if (this.saveTimer) clearTimeout(this.saveTimer)
883
978
  if (this.syncTimer) clearTimeout(this.syncTimer)
884
979
  if (this.saveRemoteTimer) clearTimeout(this.saveRemoteTimer)
@@ -897,11 +992,17 @@ export default {
897
992
  this.loadApiPanelWidth()
898
993
  this._stageObserver = new ResizeObserver((entries) => {
899
994
  for (const entry of entries) {
995
+ // 舞台与两栏容器分别实测:右栏夹取要用 colsWidth(不含预览列),不能拿舞台宽反推
996
+ if (entry.target === this.$refs.colsEl) {
997
+ this.colsWidth = Math.round(entry.contentRect.width)
998
+ continue
999
+ }
900
1000
  this.stageWidth = Math.round(entry.contentRect.width)
901
1001
  this.stageHeight = Math.round(entry.contentRect.height)
902
1002
  }
903
1003
  })
904
1004
  if (this.$refs.stageEl) this._stageObserver.observe(this.$refs.stageEl)
1005
+ if (this.$refs.colsEl) this._stageObserver.observe(this.$refs.colsEl)
905
1006
  // 全局键盘快捷键:复制/粘贴/副本/删除/撤销/重做/取消选中(见 onKeydown)
906
1007
  window.addEventListener('keydown', this.onKeydown)
907
1008
  },
@@ -1023,32 +1124,84 @@ export default {
1023
1124
  showAppToast('本地开发服务配置已保存', 'ok')
1024
1125
  this.fetchCardList()
1025
1126
  },
1026
- // —— 预览区宽度调节 ——
1027
- // 拖拽分隔条:拖动改变预览列宽度(直接写入 _previewWidthPx 像素,previewWidth/canvasW 自动联动),松手持久化
1028
- startDrag() {
1029
- if (this.chromeOff) return
1030
- this.dragging = true
1031
- document.body.style.cursor = 'col-resize'
1032
- document.body.style.userSelect = 'none'
1033
- const stage = this.$refs.stageEl
1034
- this._dragMove = (ev) => {
1035
- const rect = stage.getBoundingClientRect()
1036
- const minW = 188 // 最小宽度不得小于 188
1037
- const maxW = Math.max(minW, Math.floor(rect.width / 2)) // 最大不得超过可视宽度的一半
1038
- let w = ev.clientX - rect.left
1039
- w = Math.max(minW, Math.min(maxW, w))
1040
- this._previewWidthPx = w
1127
+ // —— 列宽拖拽(左:预览列 / 右:接口调用面板)——
1128
+ /**
1129
+ * 分隔条拖拽统一入口:Pointer Events + 指针捕获。
1130
+ * 曾经左条绑 mousedown、处理函数却按 pointer 写(ev.pointerId 为 undefined → 捕获失败,
1131
+ * pointermove 只在 6px 命中区内触发),表现为「左侧分隔条拖不动」;两条统一走这里。
1132
+ * 捕获后即使松手点在视口外,pointerup / pointercancel 仍回送本元素,不会残留拖拽态。
1133
+ * @param {PointerEvent} ev
1134
+ * @param {(e: PointerEvent) => void} onMove 每次移动的夹取写入
1135
+ * @param {() => void} onEnd 收尾(清光标 / 持久化)
1136
+ */
1137
+ beginColumnDrag(ev, onMove, onEnd) {
1138
+ const el = ev && ev.currentTarget
1139
+ if (!el) return
1140
+ const pointerId = ev.pointerId
1141
+ try {
1142
+ el.setPointerCapture(pointerId)
1143
+ } catch {
1144
+ /* 不支持捕获时退化为元素级监听 */
1041
1145
  }
1042
- this._dragUp = () => {
1043
- this.dragging = false
1146
+ const finish = () => {
1147
+ el.removeEventListener('pointermove', onMove)
1148
+ el.removeEventListener('pointerup', finish)
1149
+ el.removeEventListener('pointercancel', finish)
1150
+ window.removeEventListener('blur', finish)
1151
+ try {
1152
+ if (el.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
1153
+ } catch {
1154
+ /* 已释放 */
1155
+ }
1044
1156
  document.body.style.cursor = ''
1045
1157
  document.body.style.userSelect = ''
1046
- document.removeEventListener('mousemove', this._dragMove)
1047
- document.removeEventListener('mouseup', this._dragUp)
1048
- this.savePreviewWidth()
1158
+ onEnd()
1049
1159
  }
1050
- document.addEventListener('mousemove', this._dragMove)
1051
- document.addEventListener('mouseup', this._dragUp)
1160
+ document.body.style.cursor = 'col-resize'
1161
+ document.body.style.userSelect = 'none'
1162
+ el.addEventListener('pointermove', onMove)
1163
+ el.addEventListener('pointerup', finish)
1164
+ el.addEventListener('pointercancel', finish)
1165
+ window.addEventListener('blur', finish) // 窗口失焦兜底:切走 / 最小化不卡死拖拽态
1166
+ },
1167
+ // 左分隔条:拖动改变预览列宽度(写像素,previewWidth / canvasW 自动联动),松手持久化
1168
+ startDrag(ev) {
1169
+ if (this.chromeOff) return
1170
+ const stage = this.$refs.stageEl
1171
+ if (!stage) return
1172
+ const baseLeft = stage.getBoundingClientRect().left
1173
+ this.dragging = true
1174
+ this.beginColumnDrag(
1175
+ ev,
1176
+ (e) => {
1177
+ const { min, max } = this.previewBounds
1178
+ this._previewWidthPx = Math.max(min, Math.min(max, Math.round(e.clientX - baseLeft)))
1179
+ },
1180
+ () => {
1181
+ this.dragging = false
1182
+ this.savePreviewWidth()
1183
+ }
1184
+ )
1185
+ },
1186
+ // 右分隔条:拖动改变「接口调用」面板宽度(向左加宽 / 向右收窄),松手持久化。
1187
+ // 基准取两栏容器右缘(= 面板右缘),夹取用 apiPanelBounds,与渲染口径完全一致 → 极限处不再跳动
1188
+ startApiDrag(ev) {
1189
+ if (this.chromeOff || !this.apiPanelOpen) return
1190
+ const cols = this.$refs.colsEl
1191
+ if (!cols) return
1192
+ const baseRight = cols.getBoundingClientRect().right
1193
+ this.apiDragging = true
1194
+ this.beginColumnDrag(
1195
+ ev,
1196
+ (e) => {
1197
+ const { min, max } = this.apiPanelBounds
1198
+ this._apiPanelWidthPx = Math.max(min, Math.min(max, Math.round(baseRight - e.clientX)))
1199
+ },
1200
+ () => {
1201
+ this.apiDragging = false
1202
+ this.saveApiPanelWidth()
1203
+ }
1204
+ )
1052
1205
  },
1053
1206
  // 读取上次保存的预览列宽度(像素);首次打开(无记录)时保持默认 372px
1054
1207
  loadPreviewWidth() {
@@ -1069,40 +1222,10 @@ export default {
1069
1222
  // 预设尺寸切换:375 / 188 一键设定预览列宽度;高度随列高动态保持一致(由 CSS flex 自动填充)
1070
1223
  setPresetWidth(w) {
1071
1224
  if (this.chromeOff) return
1072
- const total = this.stageWidth || 1
1073
- const minW = 188
1074
- const maxW = Math.max(minW, Math.floor(total / 2)) // 最大不得超过可视宽度的一半
1075
- this._previewWidthPx = Math.max(minW, Math.min(maxW, w))
1225
+ const { min, max } = this.previewBounds
1226
+ this._previewWidthPx = Math.max(min, Math.min(max, w))
1076
1227
  this.savePreviewWidth()
1077
1228
  },
1078
- // —— 接口调用面板宽度调节 ——
1079
- // 拖拽分隔条:拖动改变右栏宽度(向左拖加宽、向右拖收窄),松手持久化。
1080
- // 约束:右栏 [320px, 舞台宽 - 预览列宽 - 中栏最小宽(屏宽 1/3)],与 apiPanelWidth computed 一致
1081
- startApiDrag() {
1082
- if (this.chromeOff || !this.apiPanelOpen) return
1083
- this.apiDragging = true
1084
- document.body.style.cursor = 'col-resize'
1085
- document.body.style.userSelect = 'none'
1086
- const stage = this.$refs.stageEl
1087
- this._apiDragMove = (ev) => {
1088
- const rect = stage.getBoundingClientRect()
1089
- const minW = 320 // 右栏最小宽度
1090
- const midMin = Math.floor(window.innerWidth / 3) // 中栏最小宽度:屏宽 1/3
1091
- const maxW = Math.max(minW, Math.floor(rect.width - this.previewWidth - midMin))
1092
- const w = Math.max(minW, Math.min(maxW, rect.right - ev.clientX))
1093
- this._apiPanelWidthPx = w
1094
- }
1095
- this._apiDragUp = () => {
1096
- this.apiDragging = false
1097
- document.body.style.cursor = ''
1098
- document.body.style.userSelect = ''
1099
- document.removeEventListener('mousemove', this._apiDragMove)
1100
- document.removeEventListener('mouseup', this._apiDragUp)
1101
- this.saveApiPanelWidth()
1102
- }
1103
- document.addEventListener('mousemove', this._apiDragMove)
1104
- document.addEventListener('mouseup', this._apiDragUp)
1105
- },
1106
1229
  // 读取上次保存的接口调用面板宽度(像素);首次打开(无记录)时保持默认 360px
1107
1230
  loadApiPanelWidth() {
1108
1231
  try {
@@ -1125,20 +1248,35 @@ export default {
1125
1248
  },
1126
1249
  onSelect(id) {
1127
1250
  this.loadingCard = true
1251
+ // 乐观置位:下拉立刻跟手。持久化交给 loadCard —— 载入成功才记,
1252
+ // 避免把一张打不开的卡记成「上次选中」,下次进页面直接白屏
1253
+ this.selectedId = id
1254
+ this.loadCard(id, { clearAll: true })
1255
+ },
1256
+ /**
1257
+ * 置为「当前卡」并持久化(下次打开页面自动恢复,沿用 cardbuddy: 前缀约定)。
1258
+ * 顶栏下拉、网格高亮、校验徽标、RenderTree 的 key、导入元数据的过期判定全读 selectedId,
1259
+ * 所以由 loadCard 统一置位:任何调用点(下拉选中、网格点卡、新增空白卡、删除后切下一张)
1260
+ * 都不会再出现「画布切过去了、下拉还停在旧卡」的错位。
1261
+ */
1262
+ selectCard(id) {
1128
1263
  this.selectedId = id
1129
- // 持久化当前选中卡片,下次打开页面自动恢复(沿用 cardbuddy: 前缀约定)
1130
1264
  try {
1131
1265
  localStorage.setItem('cardbuddy:selected-card', id)
1132
1266
  } catch (e) {
1133
1267
  /* localStorage 不可用时忽略 */
1134
1268
  }
1135
- this.loadCard(id, { clearAll: true })
1136
1269
  },
1137
1270
  /** 打开卡片网格预览弹窗 */
1138
1271
  openGallery() {
1139
1272
  if (!this.cardList.length) return
1140
1273
  this.galleryOpen = true
1141
1274
  },
1275
+ /** 打开「新增空白卡片」弹窗:先刷一次列表,重名判定才拿得到最新本地卡片(别的窗口 / 同步远程可能刚建过卡) */
1276
+ openNewCard() {
1277
+ this.newCardOpen = true
1278
+ this.fetchCardList()
1279
+ },
1142
1280
  /** 关闭卡片网格预览弹窗 */
1143
1281
  closeGallery() {
1144
1282
  this.galleryOpen = false
@@ -1327,6 +1465,9 @@ export default {
1327
1465
  this.rt.pushError('loadCard', 'schema 读取失败')
1328
1466
  return
1329
1467
  }
1468
+ // 载入成功即认定这张卡为当前卡:必须在 fetchImportMeta / refreshValidateBadge 之前,
1469
+ // 二者都以 selectedId 作「是否已切卡」的判据,晚置位会把新卡的结果当过期丢掉
1470
+ this.selectCard(id)
1330
1471
  // 切换卡片:清全部错误;刷新/重建预览:只清绑定错误,保留事件错误
1331
1472
  if (opts.clearAll) {
1332
1473
  this.rt.clearErrors()
@@ -1740,6 +1881,16 @@ export default {
1740
1881
  clearHoverEl()
1741
1882
  this.scheduleCommit()
1742
1883
  },
1884
+ /** 页面是否存在非空文本选区:有则视为「文本复制/粘贴」意图,交还原生行为 */
1885
+ hasTextSelection() {
1886
+ if (typeof window === 'undefined' || !window.getSelection) return false
1887
+ const sel = window.getSelection()
1888
+ return !!sel && !sel.isCollapsed && sel.toString().trim() !== ''
1889
+ },
1890
+ /** 是否存在可用于节点操作的选区(选中了非根节点):节点复制/粘贴/副本的前提 */
1891
+ hasNodeSelection() {
1892
+ return !!this.selectedNodeId && this.selectedNodeId !== '_root'
1893
+ },
1743
1894
  /** 判断按键事件焦点是否在可编辑元素内(输入框/文本域/可选文本),此时不劫持快捷键 */
1744
1895
  isEditableTarget(e) {
1745
1896
  const t = e.target
@@ -1755,6 +1906,11 @@ export default {
1755
1906
  * Ctrl/⌘+Z 撤销 · Ctrl/⌘+Shift+Z / Ctrl+Y 重做 · Esc 取消选中。
1756
1907
  * 焦点在输入框/JSON 编辑器等可编辑元素内时,仅放行 Esc,其余交还原生行为
1757
1908
  * (避免误删节点或劫持文本撤销/复制)。
1909
+ * 节点复制/粘贴与系统文本复制/粘贴共存判据(按优先级):
1910
+ * 1) 焦点可编辑 → 一律原生(文本撤销/复制/粘贴);
1911
+ * 2) 存在非空文本选区 → 一律原生(用户意图是文本);
1912
+ * 3) 节点类操作还要求已选中非根节点(粘贴另要求节点剪贴板非空),
1913
+ * 否则不 preventDefault,把按键还给浏览器(如 ⌘D 书签)。
1758
1914
  */
1759
1915
  onKeydown(e) {
1760
1916
  if (this.chromeOff || !this.preview) return
@@ -1764,18 +1920,21 @@ export default {
1764
1920
 
1765
1921
  if (ctrl && (k === 'c' || k === 'C')) {
1766
1922
  if (editable) return
1923
+ if (this.hasTextSelection() || !this.hasNodeSelection()) return
1767
1924
  this.copySelectedNode()
1768
1925
  e.preventDefault()
1769
1926
  return
1770
1927
  }
1771
1928
  if (ctrl && (k === 'v' || k === 'V')) {
1772
1929
  if (editable) return
1930
+ if (this.hasTextSelection() || !this.hasNodeSelection() || !getClipboardNode()) return
1773
1931
  this.pasteNode()
1774
1932
  e.preventDefault()
1775
1933
  return
1776
1934
  }
1777
1935
  if (ctrl && (k === 'd' || k === 'D')) {
1778
1936
  if (editable) return
1937
+ if (this.hasTextSelection() || !this.hasNodeSelection()) return
1779
1938
  this.duplicateNode()
1780
1939
  e.preventDefault()
1781
1940
  return
@@ -2569,6 +2728,37 @@ export default {
2569
2728
  console.error('[加载卡片列表失败]', e)
2570
2729
  }
2571
2730
  },
2731
+ /**
2732
+ * 网格预览删除卡片:格子删除钮 → 警告确认 → 这里真删(DELETE /__card/<id> 递归删本地目录)。
2733
+ * 删完刷列表;若删的正是当前卡,切到剩余首卡,避免工作台指向已不存在的目录(保存会 404)。
2734
+ */
2735
+ async onGalleryDelete(id) {
2736
+ try {
2737
+ const res = await apiFetch(`/__card/${encodeURIComponent(id)}`, { method: 'DELETE' })
2738
+ const r = await res.json().catch(() => null)
2739
+ if (!res.ok) throw new Error((r && (r.message || r.error)) || `HTTP ${res.status}`)
2740
+ showAppToast(`已删除本地卡片 ${id}`, 'ok')
2741
+ await this.fetchCardList()
2742
+ if (this.selectedId === id) {
2743
+ const next = this.cardList[0]
2744
+ if (next) await this.loadCard(next.id)
2745
+ else {
2746
+ this.selectedId = ''
2747
+ this.currentSchema = null
2748
+ this.preview = null
2749
+ }
2750
+ }
2751
+ } catch (e) {
2752
+ showAppToast(`删除卡片失败:${e.message}`, 'err')
2753
+ }
2754
+ },
2755
+ /** 新增空白卡片创建成功:关弹窗 → 刷列表 → 切换到新卡(空白卡无封面,网格显示占位图) */
2756
+ async onNewCardCreated(id) {
2757
+ this.newCardOpen = false
2758
+ showAppToast(`已创建空白卡片 ${id}`, 'ok')
2759
+ await this.fetchCardList()
2760
+ await this.loadCard(id)
2761
+ },
2572
2762
  /** 刷新当前卡片:从磁盘重新读取 schema/data 并强制重挂载 */
2573
2763
  async onRefresh() {
2574
2764
  const id = this.selectedId
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * 设计目标(前后端分离方案 Phase 2):
5
5
  * - 开发期(npm run dev,由 tools/dev.mjs 同起 cardbuddy):apiBase 默认
6
- * http://127.0.0.1:<FINMALL_CARD_PORT>(.env.finmall,默认 8787),与部署态一致。
6
+ * http://127.0.0.1:<FINMALL_CARD_PORT>(.env.cb,默认 8787),与部署态一致。
7
7
  * 若页面不是经回环地址打开(局域网 IP / 其它本机 IP),则退化为同源 '',
8
8
  * 由 Vite dev server 代理到 8787,避开浏览器私有网络访问(PNA)拦截。
9
9
  * - 部署态(vite build 静态产物):默认读 localStorage 的「本地开发服务」配置;
@@ -28,8 +28,8 @@ const PREVIEW_API_BASE =
28
28
  ? window.__CARD_DEV_PREVIEW_BASE__ || window.__CARD_DEV_OFFLINE_BASE__ || ''
29
29
  : ''
30
30
 
31
- // 本地开发服务默认端口:与 .env.finmall 的 FINMALL_CARD_PORT 保持一致(默认 8787)。
32
- // 开发期 .env.finmall 经 vite envPrefix 暴露为 import.meta.env.FINMALL_CARD_PORT,
31
+ // 本地开发服务默认端口:与 .env.cb 的 FINMALL_CARD_PORT 保持一致(默认 8787)。
32
+ // 开发期 .env.cb 经 vite envPrefix 暴露为 import.meta.env.FINMALL_CARD_PORT,
33
33
  // 前端据此连接;该值同时由启动器注入 cardbuddy 子进程(DSL_DEV_PORT)使其监听同一端口。
34
34
  const CARD_DEV_PORT = Number(import.meta.env.FINMALL_CARD_PORT || 8787)
35
35
 
@@ -85,7 +85,7 @@ export function saveDevServiceConfig(cfg) {
85
85
  * - 显式覆盖(VITE_API_BASE,手动 / 部署态注入)→ 该 URL
86
86
  * - 用户已在设置启用并填了地址 → 该地址(去尾斜杠)
87
87
  * - Vite dev server(import.meta.env.DEV 且无覆盖)→ 默认本地开发服务
88
- * http://127.0.0.1:<FINMALL_CARD_PORT>(.env.finmall,默认 8787)
88
+ * http://127.0.0.1:<FINMALL_CARD_PORT>(.env.cb,默认 8787)
89
89
  * (前后端分离:Vite 不再托管卡片后端,改由 tools/dev.mjs 同起的 cardbuddy 提供)
90
90
  * 例外:页面经非回环主机名打开时返回 ''(同源),改由 Vite dev server 代理到 8787,
91
91
  * 绕开浏览器私有网络访问(PNA)拦截,详见 isNonLoopbackDevHost 注释
@@ -8,6 +8,7 @@
8
8
  <symbol id="icon-save" viewBox="0 0 24 24"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></symbol>
9
9
  <symbol id="icon-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></symbol>
10
10
  <symbol id="icon-loading" viewBox="0 0 24 24"><line x1="12" y1="2" x2="12" y2="6"></line><line x1="12" y1="18" x2="12" y2="22"></line><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"></line><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"></line><line x1="2" y1="12" x2="6" y2="12"></line><line x1="18" y1="12" x2="22" y2="12"></line><line x1="4.93" y1="19.07" x2="7.76" y2="16.24"></line><line x1="16.24" y1="7.76" x2="19.07" y2="4.93"></line></symbol>
11
+ <symbol id="icon-activity" viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></symbol>
11
12
  <symbol id="icon-cloud-check" viewBox="0 0 24 24"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path><polyline points="9 15 11 17 14 13"></polyline></symbol>
12
13
  <symbol id="icon-cloud-alert" viewBox="0 0 24 24"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path><line x1="12" y1="13" x2="12" y2="16"></line><line x1="12" y1="18" x2="12.01" y2="18"></line></symbol>
13
14
  <symbol id="icon-cloud" viewBox="0 0 24 24"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"></path></symbol>
@@ -27,6 +28,33 @@
27
28
  <!-- 连接(link):本地开发服务「连接前后端」语义图标,呼应主题【连接】 -->
28
29
  <symbol id="icon-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></symbol>
29
30
 
31
+ <!-- AI 开发助手(sparkles):悬浮入口 / 面板标题 / 思考态,与主题「智能辅助」呼应 -->
32
+ <symbol id="icon-sparkles" viewBox="0 0 24 24"><path d="M12 3l1.9 4.9L18.8 9.8l-4.9 1.9L12 16.6l-1.9-4.9L5.2 9.8l4.9-1.9L12 3z"></path><path d="M18 15.5l.9 2.2 2.2.9-2.2.9-.9 2.2-.9-2.2-2.2-.9 2.2-.9.9-2.2z"></path></symbol>
33
+ <!-- 终止生成(实心方块):流式进行中的「停止」按钮 -->
34
+ <symbol id="icon-stop" viewBox="0 0 24 24"><rect x="6" y="6" width="12" height="12" rx="2" fill="currentColor" stroke="none"></rect></symbol>
35
+ <!-- 发送(arrow-up):输入框发送态 -->
36
+ <symbol id="icon-send" viewBox="0 0 24 24"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></symbol>
37
+ <!-- 附件(paperclip):本地文件 / 图片挂载入口 -->
38
+ <symbol id="icon-paperclip" viewBox="0 0 24 24"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></symbol>
39
+ <!-- 文件夹加号:挂载本地文件夹(如 Sketch MeaXure 标注导出目录) -->
40
+ <symbol id="icon-folder-plus" viewBox="0 0 24 24"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path><line x1="12" y1="11" x2="12" y2="17"></line><line x1="9" y1="14" x2="15" y2="14"></line></symbol>
41
+ <!-- 图片(image):图片类附件缩略占位 -->
42
+ <symbol id="icon-image" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></symbol>
43
+ <!-- 文件(file-text):文本类附件占位 -->
44
+ <symbol id="icon-file-text" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></symbol>
45
+ <!-- 关闭(x):面板 / 弹层关闭,替代文本 ✕ -->
46
+ <symbol id="icon-x" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></symbol>
47
+ <!-- 工具调用(wrench):assistant 消息里的工具执行行 -->
48
+ <symbol id="icon-eye" viewBox="0 0 24 24"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></symbol>
49
+ <symbol id="icon-eye-off" viewBox="0 0 24 24"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"></path><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"></path><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></symbol>
50
+ <symbol id="icon-wrench" viewBox="0 0 24 24"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path></symbol>
51
+ <!-- 回到底部(arrow-down):滚动脱离底部时的悬浮按钮 -->
52
+ <symbol id="icon-arrow-down" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"></line><polyline points="19 12 12 19 5 12"></polyline></symbol>
53
+ <!-- 帮助(help-circle):表单字段的「?」详情入口,点开看说明浮层 -->
54
+ <symbol id="icon-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></symbol>
55
+ <!-- 展开箭头(chevron-right):折叠行展开态,CSS 旋转 90° 即为收起态 -->
56
+ <symbol id="icon-chevron-right" viewBox="0 0 24 24"><polyline points="9 18 15 12 9 6"></polyline></symbol>
57
+
30
58
  <!-- ===== 16x16 小图标(下拉 / 卡片选择 / 设置器) ===== -->
31
59
  <symbol id="icon-chevron" viewBox="0 0 16 16"><path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"></path></symbol>
32
60
  <symbol id="icon-check" viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"></path></symbol>