@open-slide/core 1.3.0 → 1.4.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 (38) hide show
  1. package/dist/{build-_276DMmJ.js → build-1Rqivz0d.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-BAwKWNtW.js → config-XZJnC_fu.js} +533 -59
  4. package/dist/{config-D9cZ1A0X.d.ts → config-s0YUbmUe.d.ts} +2 -1
  5. package/dist/{dev-BoqeVXVq.js → dev-0W8gYiSa.js} +1 -1
  6. package/dist/{en-CDKzoZvf.js → en-7GU-DHbJ.js} +13 -3
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +40 -10
  11. package/dist/{preview-BLPxspc9.js → preview-DT9hJvzM.js} +1 -1
  12. package/dist/{types-JYG1cmwC.d.ts → types-QCpkHkiS.d.ts} +11 -1
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/slide-authoring/SKILL.md +10 -2
  17. package/src/app/app.tsx +2 -0
  18. package/src/app/components/asset-view.tsx +36 -9
  19. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  20. package/src/app/components/inspector/inspector-panel.tsx +251 -24
  21. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  22. package/src/app/components/player.tsx +25 -5
  23. package/src/app/components/present/control-bar.tsx +12 -0
  24. package/src/app/components/sidebar/folder-item.tsx +14 -3
  25. package/src/app/components/sidebar/sidebar.tsx +10 -0
  26. package/src/app/lib/export-pdf.ts +6 -0
  27. package/src/app/lib/inspector/use-editor.ts +9 -1
  28. package/src/app/lib/slides.ts +7 -0
  29. package/src/app/lib/use-slide-module.ts +48 -0
  30. package/src/app/routes/assets.tsx +9 -0
  31. package/src/app/routes/home-shell.tsx +23 -2
  32. package/src/app/routes/presenter.tsx +2 -20
  33. package/src/app/routes/slide.tsx +73 -40
  34. package/src/locale/en.ts +14 -4
  35. package/src/locale/ja.ts +14 -4
  36. package/src/locale/types.ts +11 -1
  37. package/src/locale/zh-cn.ts +14 -5
  38. package/src/locale/zh-tw.ts +14 -5
@@ -1,4 +1,4 @@
1
- import { Locale } from "./types-JYG1cmwC.js";
1
+ import { Locale } from "./types-QCpkHkiS.js";
2
2
 
3
3
  //#region src/config.d.ts
4
4
  type OpenSlideBuildConfig = {
@@ -9,6 +9,7 @@ type OpenSlideBuildConfig = {
9
9
  type OpenSlideConfig = {
10
10
  slidesDir?: string;
11
11
  themesDir?: string;
12
+ assetsDir?: string;
12
13
  port?: number;
13
14
  locale?: Locale;
14
15
  build?: OpenSlideBuildConfig;
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-BAwKWNtW.js";
2
+ import { createViteConfig } from "./config-XZJnC_fu.js";
3
3
  import { createServer, mergeConfig } from "vite";
4
4
 
5
5
  //#region src/cli/dev.ts
@@ -35,6 +35,7 @@ const en = {
35
35
  appTitle: "open-slide",
36
36
  draft: "Draft",
37
37
  themes: "Themes",
38
+ assets: "Assets",
38
39
  folders: "Folders",
39
40
  newFolder: "New folder",
40
41
  folderName: "Folder name",
@@ -79,14 +80,19 @@ const en = {
79
80
  home: "Home",
80
81
  backToHome: "Back to home",
81
82
  agentConnected: "Agent connected",
82
- agentConnectedTooltip: "The dev server is publishing your current slide and inspector selection to your agent. Ask \"this slide\" or \"this element\" in chat and it will resolve. Disappears in production builds.",
83
+ agentConnectedTooltip: "The current slide and inspector selection are synced to your agent in real time.",
83
84
  agentDisconnected: "Agent disconnected",
84
85
  agentDisconnectedTooltip: "Lost connection to the dev server, so your agent can no longer see the current slide or inspector selection. Restart the dev server to restore the connection.",
85
86
  download: "Download",
86
87
  exportAsHtml: "Export as HTML",
87
88
  exportAsPdf: "Export as PDF",
88
89
  pdfExportFailed: "PDF export failed",
90
+ pdfExportSafariUnsupported: "Export as PDF is not supported on Safari. Please try a Chromium-based browser instead.",
89
91
  present: "Present",
92
+ presentMenuAria: "Present options",
93
+ presentInWindow: "Play",
94
+ presentFullscreen: "Fullscreen",
95
+ presentPresenter: "Presenter mode",
90
96
  slidesTab: "Slides",
91
97
  assetsTab: "Assets",
92
98
  renameSlide: "Rename slide",
@@ -128,6 +134,8 @@ const en = {
128
134
  whiteoutAria: "White screen (W)",
129
135
  laserAria: "Laser pointer (L)",
130
136
  presenterAria: "Presenter view (P)",
137
+ enterFullscreenAria: "Enter fullscreen",
138
+ exitFullscreenAria: "Exit fullscreen",
131
139
  helpAria: "Keyboard shortcuts (?)",
132
140
  exitAria: "Exit (Esc)",
133
141
  elapsedTime: "Elapsed time",
@@ -153,7 +161,7 @@ const en = {
153
161
  inspect: "Inspect",
154
162
  deselect: "Deselect",
155
163
  agentWatching: "Agent is watching",
156
- agentWatchingTooltip: "Your agent already sees the selected element via the dev server just ask it in chat. Leave comments here only when you want to queue a few before asking.",
164
+ agentWatchingTooltip: "The selected element is synced to your agent in real time.",
157
165
  agentNotWatching: "Agent not watching",
158
166
  agentNotWatchingTooltip: "Lost connection to the dev server, so your agent can no longer see the selected element. Restart the dev server to restore the connection.",
159
167
  contentSection: "Content",
@@ -237,6 +245,8 @@ const en = {
237
245
  devOnlyMessage: "Asset management is only available in dev mode.",
238
246
  sectionAria: "Slide assets",
239
247
  eyebrow: "Assets",
248
+ scopeSlide: "This slide",
249
+ scopeGlobal: "Global",
240
250
  fileCount: {
241
251
  one: "{count} file",
242
252
  other: "{count} files"
@@ -255,7 +265,7 @@ const en = {
255
265
  renameMenuItem: "Rename",
256
266
  deleteMenuItem: "Delete",
257
267
  conflictTitle: "File already exists",
258
- conflictDescription: "{name} is already in this slide's assets folder.",
268
+ conflictDescription: "{name} is already in the assets folder.",
259
269
  conflictReplace: "Replace",
260
270
  conflictRenameCopy: "Rename copy",
261
271
  deleteAssetTitle: "Delete asset",
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Locale, Plural } from "./types-JYG1cmwC.js";
2
- import { OpenSlideConfig } from "./config-D9cZ1A0X.js";
1
+ import { Locale, Plural } from "./types-QCpkHkiS.js";
2
+ import { OpenSlideConfig } from "./config-s0YUbmUe.js";
3
3
  import { CSSProperties, ComponentType, HTMLAttributes } from "react";
4
4
  import * as react_jsx_runtime0 from "react/jsx-runtime";
5
5
 
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { en } from "./en-CDKzoZvf.js";
1
+ import { en } from "./en-7GU-DHbJ.js";
2
2
  import { cssVarsToString, defaultDesign, designToCssVars } from "./design-cpzS8aud.js";
3
3
  import { useRef, useState } from "react";
4
4
  import { toast } from "sonner";
@@ -1,4 +1,4 @@
1
- import { Locale, Plural } from "../types-JYG1cmwC.js";
1
+ import { Locale, Plural } from "../types-QCpkHkiS.js";
2
2
 
3
3
  //#region src/locale/en.d.ts
4
4
  declare const en: Locale;
@@ -1,4 +1,4 @@
1
- import { en } from "../en-CDKzoZvf.js";
1
+ import { en } from "../en-7GU-DHbJ.js";
2
2
 
3
3
  //#region src/locale/format.ts
4
4
  function format(template, vars) {
@@ -49,6 +49,7 @@ const ja = {
49
49
  appTitle: "open-slide",
50
50
  draft: "下書き",
51
51
  themes: "テーマ",
52
+ assets: "アセット",
52
53
  folders: "フォルダ",
53
54
  newFolder: "新規フォルダ",
54
55
  folderName: "フォルダ名",
@@ -91,7 +92,7 @@ const ja = {
91
92
  },
92
93
  slide: {
93
94
  agentConnected: "エージェント接続中",
94
- agentConnectedTooltip: "現在のスライドと Inspector の選択状態を dev server がエージェントに公開しています。チャットで「このスライド」「この要素」と言えば認識されます。本番ビルドでは表示されません。",
95
+ agentConnectedTooltip: "現在のスライドと Inspector の選択はエージェントにリアルタイムで同期されています。",
95
96
  agentDisconnected: "エージェント切断",
96
97
  agentDisconnectedTooltip: "dev server との接続が切れたため、現在のスライドや Inspector の選択がエージェントに届かなくなっています。dev server を再起動して接続を復旧してください。",
97
98
  home: "ホーム",
@@ -100,7 +101,12 @@ const ja = {
100
101
  exportAsHtml: "HTML として書き出し",
101
102
  exportAsPdf: "PDF として書き出し",
102
103
  pdfExportFailed: "PDF の書き出しに失敗しました",
104
+ pdfExportSafariUnsupported: "PDF の書き出しは現在 Safari では対応していません。Chromium ベースのブラウザでお試しください。",
103
105
  present: "発表",
106
+ presentMenuAria: "発表オプション",
107
+ presentInWindow: "再生",
108
+ presentFullscreen: "フルスクリーン再生",
109
+ presentPresenter: "発表者モード",
104
110
  slidesTab: "スライド",
105
111
  assetsTab: "アセット",
106
112
  renameSlide: "スライドの名前を変更",
@@ -142,6 +148,8 @@ const ja = {
142
148
  whiteoutAria: "白い画面 (W)",
143
149
  laserAria: "レーザーポインタ (L)",
144
150
  presenterAria: "発表者ビュー (P)",
151
+ enterFullscreenAria: "フルスクリーンへ",
152
+ exitFullscreenAria: "フルスクリーン解除",
145
153
  helpAria: "キーボードショートカット (?)",
146
154
  exitAria: "終了 (Esc)",
147
155
  elapsedTime: "経過時間",
@@ -203,7 +211,7 @@ const ja = {
203
211
  cropApply: "適用",
204
212
  cropResetAria: "トリミングをリセット",
205
213
  agentWatching: "エージェント監視中",
206
- agentWatchingTooltip: "エージェントは選択中の要素を dev server 経由で把握しています。直接チャットで頼めます。ここにコメントを残すのは、複数の依頼をまとめて出したいときだけで OK。",
214
+ agentWatchingTooltip: "選択中の要素はエージェントにリアルタイムで同期されています。",
207
215
  agentNotWatching: "エージェント未接続",
208
216
  agentNotWatchingTooltip: "dev server との接続が切れたため、選択中の要素がエージェントに見えなくなっています。dev server を再起動して接続を復旧してください。",
209
217
  leaveComment: "コメントを残す",
@@ -251,6 +259,8 @@ const ja = {
251
259
  devOnlyMessage: "アセット管理は開発モードでのみ利用できます。",
252
260
  sectionAria: "スライドのアセット",
253
261
  eyebrow: "アセット",
262
+ scopeSlide: "このスライド",
263
+ scopeGlobal: "グローバル",
254
264
  fileCount: {
255
265
  one: "ファイル {count} 件",
256
266
  other: "ファイル {count} 件"
@@ -269,7 +279,7 @@ const ja = {
269
279
  renameMenuItem: "名前を変更",
270
280
  deleteMenuItem: "削除",
271
281
  conflictTitle: "ファイルがすでに存在します",
272
- conflictDescription: "{name} はすでにこのスライドのアセットフォルダにあります。",
282
+ conflictDescription: "{name} はすでにアセットフォルダにあります。",
273
283
  conflictReplace: "置き換え",
274
284
  conflictRenameCopy: "コピーをリネーム",
275
285
  deleteAssetTitle: "アセットを削除",
@@ -399,6 +409,7 @@ const zhCN = {
399
409
  appTitle: "open-slide",
400
410
  draft: "草稿",
401
411
  themes: "主题",
412
+ assets: "素材",
402
413
  folders: "文件夹",
403
414
  newFolder: "新建文件夹",
404
415
  folderName: "文件夹名称",
@@ -441,7 +452,7 @@ const zhCN = {
441
452
  },
442
453
  slide: {
443
454
  agentConnected: "Agent 已连接",
444
- agentConnectedTooltip: "Dev server 正在把你目前在哪张 slide、Inspector 选了哪个元素发布给 agent。直接到聊天说\"这张 slide\"或\"这个元素\"就行。Production build 不会出现。",
455
+ agentConnectedTooltip: "目前的 slide Inspector 选择会即时同步给 agent",
445
456
  agentDisconnected: "Agent 已断开",
446
457
  agentDisconnectedTooltip: "已和 dev server 断开连接,agent 没办法再看到你目前的 slide 或 Inspector 选择。请重新启动 dev server 来恢复连接。",
447
458
  home: "首页",
@@ -450,7 +461,12 @@ const zhCN = {
450
461
  exportAsHtml: "导出为 HTML",
451
462
  exportAsPdf: "导出为 PDF",
452
463
  pdfExportFailed: "PDF 导出失败",
464
+ pdfExportSafariUnsupported: "导出 PDF 目前不支持 Safari 设备,请尝试使用基于 Chromium 的浏览器替代。",
453
465
  present: "演示",
466
+ presentMenuAria: "演示选项",
467
+ presentInWindow: "播放",
468
+ presentFullscreen: "全屏播放",
469
+ presentPresenter: "演讲者模式",
454
470
  slidesTab: "幻灯片",
455
471
  assetsTab: "素材",
456
472
  renameSlide: "重命名幻灯片",
@@ -492,6 +508,8 @@ const zhCN = {
492
508
  whiteoutAria: "白屏 (W)",
493
509
  laserAria: "激光笔 (L)",
494
510
  presenterAria: "演讲者视图 (P)",
511
+ enterFullscreenAria: "进入全屏",
512
+ exitFullscreenAria: "退出全屏",
495
513
  helpAria: "键盘快捷键 (?)",
496
514
  exitAria: "退出 (Esc)",
497
515
  elapsedTime: "已用时",
@@ -553,7 +571,7 @@ const zhCN = {
553
571
  cropApply: "应用",
554
572
  cropResetAria: "重置裁剪",
555
573
  agentWatching: "Agent 正在关注",
556
- agentWatchingTooltip: "Agent 已经通过 dev server 看到你选的元素了,直接到聊天请它修改就行。想累积几个再一次问才需要在这里留 comments。",
574
+ agentWatchingTooltip: "选取的元素会即时同步给 agent。",
557
575
  agentNotWatching: "Agent 没在关注",
558
576
  agentNotWatchingTooltip: "已和 dev server 断开连接,agent 看不到你选的元素了。请重新启动 dev server 来恢复连接。",
559
577
  leaveComment: "留个 comment",
@@ -601,6 +619,8 @@ const zhCN = {
601
619
  devOnlyMessage: "素材管理仅在开发模式下可用。",
602
620
  sectionAria: "幻灯片素材",
603
621
  eyebrow: "素材",
622
+ scopeSlide: "当前幻灯片",
623
+ scopeGlobal: "全局",
604
624
  fileCount: {
605
625
  one: "{count} 个文件",
606
626
  other: "{count} 个文件"
@@ -619,7 +639,7 @@ const zhCN = {
619
639
  renameMenuItem: "重命名",
620
640
  deleteMenuItem: "删除",
621
641
  conflictTitle: "文件已存在",
622
- conflictDescription: "{name} 已在该幻灯片的素材文件夹中。",
642
+ conflictDescription: "{name} 已在素材文件夹中。",
623
643
  conflictReplace: "替换",
624
644
  conflictRenameCopy: "重命名副本",
625
645
  deleteAssetTitle: "删除素材",
@@ -749,6 +769,7 @@ const zhTW = {
749
769
  appTitle: "open-slide",
750
770
  draft: "草稿",
751
771
  themes: "主題",
772
+ assets: "素材",
752
773
  folders: "資料夾",
753
774
  newFolder: "新增資料夾",
754
775
  folderName: "資料夾名稱",
@@ -791,7 +812,7 @@ const zhTW = {
791
812
  },
792
813
  slide: {
793
814
  agentConnected: "Agent 已連線",
794
- agentConnectedTooltip: "Dev server 正在把你目前在哪張 slide、Inspector 選了哪個元素發布給 agent。直接到聊天說「這張 slide」或「這個元素」就行。Production build 不會出現。",
815
+ agentConnectedTooltip: "目前的 slide Inspector 選擇會即時同步給 agent",
795
816
  agentDisconnected: "Agent 已斷線",
796
817
  agentDisconnectedTooltip: "已和 dev server 斷線,agent 沒辦法再看到你目前的 slide 或 Inspector 選擇。請重新啟動 dev server 來恢復連線。",
797
818
  home: "首頁",
@@ -800,7 +821,12 @@ const zhTW = {
800
821
  exportAsHtml: "匯出為 HTML",
801
822
  exportAsPdf: "匯出為 PDF",
802
823
  pdfExportFailed: "PDF 匯出失敗",
824
+ pdfExportSafariUnsupported: "匯出 PDF 目前不支援 Safari 裝置,請嘗試用 Chromium 基底瀏覽器替代。",
803
825
  present: "簡報",
826
+ presentMenuAria: "簡報選項",
827
+ presentInWindow: "播放",
828
+ presentFullscreen: "全螢幕播放",
829
+ presentPresenter: "簡報者模式",
804
830
  slidesTab: "投影片",
805
831
  assetsTab: "素材",
806
832
  renameSlide: "重新命名投影片",
@@ -842,6 +868,8 @@ const zhTW = {
842
868
  whiteoutAria: "白屏 (W)",
843
869
  laserAria: "雷射筆 (L)",
844
870
  presenterAria: "主講人檢視 (P)",
871
+ enterFullscreenAria: "進入全螢幕",
872
+ exitFullscreenAria: "退出全螢幕",
845
873
  helpAria: "鍵盤快速鍵 (?)",
846
874
  exitAria: "離開 (Esc)",
847
875
  elapsedTime: "已耗時",
@@ -903,7 +931,7 @@ const zhTW = {
903
931
  cropApply: "套用",
904
932
  cropResetAria: "重設裁切",
905
933
  agentWatching: "Agent 正在關注",
906
- agentWatchingTooltip: "Agent 已經透過 dev server 看到你選的元素了,直接到聊天請它修改就行。想累積幾個再一次問才需要在這裡留 comments。",
934
+ agentWatchingTooltip: "選取的元素會即時同步給 agent。",
907
935
  agentNotWatching: "Agent 沒在關注",
908
936
  agentNotWatchingTooltip: "已和 dev server 斷線,agent 看不到你選的元素了。請重新啟動 dev server 來恢復連線。",
909
937
  leaveComment: "留個 comment",
@@ -951,6 +979,8 @@ const zhTW = {
951
979
  devOnlyMessage: "素材管理僅在開發模式下可用。",
952
980
  sectionAria: "投影片素材",
953
981
  eyebrow: "素材",
982
+ scopeSlide: "此投影片",
983
+ scopeGlobal: "全域",
954
984
  fileCount: {
955
985
  one: "{count} 個檔案",
956
986
  other: "{count} 個檔案"
@@ -969,7 +999,7 @@ const zhTW = {
969
999
  renameMenuItem: "重新命名",
970
1000
  deleteMenuItem: "刪除",
971
1001
  conflictTitle: "檔案已存在",
972
- conflictDescription: "{name} 已在此投影片的素材資料夾中。",
1002
+ conflictDescription: "{name} 已在素材資料夾中。",
973
1003
  conflictReplace: "取代",
974
1004
  conflictRenameCopy: "重新命名副本",
975
1005
  deleteAssetTitle: "刪除素材",
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-BAwKWNtW.js";
2
+ import { createViteConfig } from "./config-XZJnC_fu.js";
3
3
  import { mergeConfig, preview as preview$1 } from "vite";
4
4
 
5
5
  //#region src/cli/preview.ts
@@ -39,6 +39,7 @@ type Locale = {
39
39
  appTitle: string;
40
40
  draft: string;
41
41
  themes: string;
42
+ assets: string;
42
43
  folders: string;
43
44
  newFolder: string;
44
45
  folderName: string;
@@ -93,7 +94,12 @@ type Locale = {
93
94
  exportAsHtml: string;
94
95
  exportAsPdf: string;
95
96
  pdfExportFailed: string;
97
+ pdfExportSafariUnsupported: string;
96
98
  present: string;
99
+ presentMenuAria: string;
100
+ presentInWindow: string;
101
+ presentFullscreen: string;
102
+ presentPresenter: string;
97
103
  slidesTab: string;
98
104
  assetsTab: string;
99
105
  renameSlide: string;
@@ -136,6 +142,8 @@ type Locale = {
136
142
  whiteoutAria: string;
137
143
  laserAria: string;
138
144
  presenterAria: string;
145
+ enterFullscreenAria: string;
146
+ exitFullscreenAria: string;
139
147
  helpAria: string;
140
148
  exitAria: string;
141
149
  elapsedTime: string;
@@ -245,6 +253,8 @@ type Locale = {
245
253
  devOnlyMessage: string;
246
254
  sectionAria: string;
247
255
  eyebrow: string;
256
+ scopeSlide: string;
257
+ scopeGlobal: string;
248
258
  /** templates: "{count} file" / "{count} files" */
249
259
  fileCount: Plural;
250
260
  searchLogos: string;
@@ -263,7 +273,7 @@ type Locale = {
263
273
  renameMenuItem: string;
264
274
  deleteMenuItem: string;
265
275
  conflictTitle: string;
266
- /** template: "{name} is already in this slide's assets folder." */
276
+ /** template: "{name} is already in the assets folder." */
267
277
  conflictDescription: string;
268
278
  conflictReplace: string;
269
279
  conflictRenameCopy: string;
@@ -1,5 +1,5 @@
1
- import "../types-JYG1cmwC.js";
2
- import { OpenSlideConfig } from "../config-D9cZ1A0X.js";
1
+ import "../types-QCpkHkiS.js";
2
+ import { OpenSlideConfig } from "../config-s0YUbmUe.js";
3
3
  import { InlineConfig } from "vite";
4
4
 
5
5
  //#region src/vite/config.d.ts
@@ -1,4 +1,4 @@
1
1
  import "../design-cpzS8aud.js";
2
- import { createViteConfig } from "../config-BAwKWNtW.js";
2
+ import { createViteConfig } from "../config-XZJnC_fu.js";
3
3
 
4
4
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -242,7 +242,7 @@ export default [Cover, Content] satisfies Page[];
242
242
 
243
243
  ## Assets
244
244
 
245
- Place files under `slides/<id>/assets/`. Import them as ES modules:
245
+ **Slide-local assets** live under `slides/<id>/assets/` — anything one-off to a single slide. Import them as ES modules:
246
246
 
247
247
  ```tsx
248
248
  import hero from './assets/hero.jpg';
@@ -256,6 +256,14 @@ For URL-only access:
256
256
  const videoUrl = new URL('./assets/intro.mp4', import.meta.url).href;
257
257
  ```
258
258
 
259
+ **Global assets** — anything reused across decks or themes (company logos, presenter avatars, recurring icons) — live in the project root `assets/` folder. Import them via the `@assets` alias:
260
+
261
+ ```tsx
262
+ import logo from '@assets/logos/acme.svg';
263
+ ```
264
+
265
+ A `themes/*.md` file may name an asset path in its prose (e.g. "use `@assets/logos/acme.svg` in the title slot"); the slide imports it explicitly.
266
+
259
267
  Skip the `assets/` folder entirely for pure-text slides.
260
268
 
261
269
  ## Image placeholders
@@ -339,7 +347,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
339
347
  - [ ] Slide declares a top-level `export const design: DesignSystem = { … }` and references the values via `var(--osd-X)` (use `design.X` only when you need a JS number for arithmetic). Only omit the `design` const for a one-off slide whose palette is intentionally locked.
340
348
  - [ ] One idea per page.
341
349
  - [ ] Visually repeated elements (cards, tiles, logo rows) are rendered as explicit `<Component />` instances, not via `array.map` over a data list.
342
- - [ ] All imported assets exist on disk under `slides/<id>/assets/`.
350
+ - [ ] All imported assets exist on disk — slide-local under `slides/<id>/assets/`, or global under `assets/` (imported via `@assets/...`).
343
351
  - [ ] Every `<ImagePlaceholder>` corresponds to a real image the user must supply — not decorative filler. If it could be replaced by typography or layout, it should be.
344
352
  - [ ] Nothing outside `slides/<id>/` was edited.
345
353
 
package/src/app/app.tsx CHANGED
@@ -2,6 +2,7 @@ import config from 'virtual:open-slide/config';
2
2
  import { BrowserRouter, Route, Routes } from 'react-router-dom';
3
3
  import { Toaster } from './components/ui/sonner';
4
4
  import { useLocale } from './lib/use-locale';
5
+ import { AssetsPage } from './routes/assets';
5
6
  import { Home } from './routes/home';
6
7
  import { HomeShell } from './routes/home-shell';
7
8
  import { Presenter } from './routes/presenter';
@@ -17,6 +18,7 @@ export function App() {
17
18
  <Route path="/" element={<Home />} />
18
19
  <Route path="/themes" element={<ThemesGalleryPage />} />
19
20
  <Route path="/themes/:themeId" element={<ThemeDetailPage />} />
21
+ <Route path="/assets" element={<AssetsPage />} />
20
22
  </Route>
21
23
  ) : (
22
24
  <Route path="/" element={<NotFound />} />
@@ -30,6 +30,7 @@ import {
30
30
  DropdownMenuItem,
31
31
  DropdownMenuTrigger,
32
32
  } from '@/components/ui/dropdown-menu';
33
+ import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
33
34
  import {
34
35
  type AssetEntry,
35
36
  fetchSvgAsFile,
@@ -41,7 +42,11 @@ import {
41
42
  import { format, useLocale } from '@/lib/use-locale';
42
43
  import { cn } from '@/lib/utils';
43
44
 
44
- type Props = { slideId: string };
45
+ type Props = { slideId: string | null };
46
+
47
+ type Scope = 'slide' | 'global';
48
+
49
+ const GLOBAL_SLIDE_ID = '@global';
45
50
 
46
51
  type ConflictState = {
47
52
  file: File;
@@ -49,7 +54,10 @@ type ConflictState = {
49
54
  };
50
55
 
51
56
  export function AssetView({ slideId }: Props) {
52
- const { assets, loading, available, upload, rename, remove } = useAssets(slideId);
57
+ const lockedToGlobal = slideId === null;
58
+ const [scope, setScope] = useState<Scope>(lockedToGlobal ? 'global' : 'slide');
59
+ const effectiveSlideId = scope === 'global' || slideId === null ? GLOBAL_SLIDE_ID : slideId;
60
+ const { assets, loading, available, upload, rename, remove } = useAssets(effectiveSlideId);
53
61
  const [dragActive, setDragActive] = useState(false);
54
62
  const [conflict, setConflict] = useState<ConflictState | null>(null);
55
63
  const [preview, setPreview] = useState<AssetEntry | null>(null);
@@ -133,10 +141,21 @@ export function AssetView({ slideId }: Props) {
133
141
  }}
134
142
  >
135
143
  <div className="flex shrink-0 items-center justify-between gap-3 border-b border-hairline bg-sidebar px-6 py-3">
136
- <div className="min-w-0">
137
- <span className="eyebrow">{t.asset.eyebrow}</span>
138
- <p className="mt-0.5 truncate text-[12px] text-muted-foreground">
139
- <span className="font-mono text-[11.5px]">slides/{slideId}/assets/</span>
144
+ <div className="flex min-w-0 items-center gap-3">
145
+ {lockedToGlobal ? (
146
+ <span className="eyebrow">{t.asset.eyebrow}</span>
147
+ ) : (
148
+ <Tabs value={scope} onValueChange={(next) => setScope(next as Scope)}>
149
+ <TabsList>
150
+ <TabsTrigger value="slide">{t.asset.scopeSlide}</TabsTrigger>
151
+ <TabsTrigger value="global">{t.asset.scopeGlobal}</TabsTrigger>
152
+ </TabsList>
153
+ </Tabs>
154
+ )}
155
+ <p className="min-w-0 truncate text-[12px] text-muted-foreground">
156
+ <span className="font-mono text-[11.5px]">
157
+ {scope === 'global' ? 'assets/' : `slides/${slideId}/assets/`}
158
+ </span>
140
159
  {!loading && (
141
160
  <>
142
161
  <span className="mx-2 opacity-50">·</span>
@@ -274,7 +293,7 @@ export function AssetView({ slideId }: Props) {
274
293
  />
275
294
  )}
276
295
 
277
- {preview && <PreviewDialog asset={preview} onClose={() => setPreview(null)} />}
296
+ {preview && <PreviewDialog asset={preview} scope={scope} onClose={() => setPreview(null)} />}
278
297
 
279
298
  {logoSearchOpen && (
280
299
  <LogoSearchDialog
@@ -542,9 +561,17 @@ function NoResultsMessage({ query, t }: { query: string; t: ReturnType<typeof us
542
561
  );
543
562
  }
544
563
 
545
- function PreviewDialog({ asset, onClose }: { asset: AssetEntry; onClose: () => void }) {
564
+ function PreviewDialog({
565
+ asset,
566
+ scope,
567
+ onClose,
568
+ }: {
569
+ asset: AssetEntry;
570
+ scope: Scope;
571
+ onClose: () => void;
572
+ }) {
546
573
  const isImage = asset.mime.startsWith('image/');
547
- const importPath = `./assets/${asset.name}`;
574
+ const importPath = scope === 'global' ? `@assets/${asset.name}` : `./assets/${asset.name}`;
548
575
  const t = useLocale();
549
576
  return (
550
577
  <Dialog open onOpenChange={(open) => !open && onClose()}>
@@ -31,7 +31,7 @@ export function InspectOverlay() {
31
31
  };
32
32
 
33
33
  const onMove = (e: PointerEvent) => {
34
- const el = pickElement(e.clientX, e.clientY);
34
+ const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
35
35
  if (!el) return setHover(null);
36
36
  const hit = findSlideSource(el, slideId, { hostOnly: true });
37
37
  if (!hit) return setHover(null);
@@ -40,7 +40,7 @@ export function InspectOverlay() {
40
40
 
41
41
  const onClick = (e: MouseEvent) => {
42
42
  if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
43
- const el = pickElement(e.clientX, e.clientY);
43
+ const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
44
44
  if (!el) return;
45
45
  const hit = findSlideSource(el, slideId, { hostOnly: true });
46
46
  if (!hit) return;
@@ -52,7 +52,7 @@ export function InspectOverlay() {
52
52
 
53
53
  const onDblClick = (e: MouseEvent) => {
54
54
  if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
55
- const el = pickElement(e.clientX, e.clientY);
55
+ const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
56
56
  if (!el) return;
57
57
  const hit = findSlideSource(el, slideId, { hostOnly: true });
58
58
  if (!hit) return;
@@ -221,3 +221,49 @@ function pickElement(x: number, y: number): HTMLElement | null {
221
221
  }
222
222
  return null;
223
223
  }
224
+
225
+ const INLINE_TEXT_TAGS = new Set([
226
+ 'B',
227
+ 'CODE',
228
+ 'DEL',
229
+ 'EM',
230
+ 'I',
231
+ 'INS',
232
+ 'MARK',
233
+ 'S',
234
+ 'SMALL',
235
+ 'SPAN',
236
+ 'STRONG',
237
+ 'SUB',
238
+ 'SUP',
239
+ 'U',
240
+ ]);
241
+
242
+ function pickInspectorTarget(el: HTMLElement | null): HTMLElement | null {
243
+ if (!el) return null;
244
+ const root = el.closest('[data-inspector-root]');
245
+ const startedOnInlineText = INLINE_TEXT_TAGS.has(el.tagName);
246
+ for (let cur: HTMLElement | null = el; cur && root?.contains(cur); cur = cur.parentElement) {
247
+ if (startedOnInlineText && INLINE_TEXT_TAGS.has(cur.tagName)) continue;
248
+ if (isEditableTextContainer(cur)) return cur;
249
+ }
250
+ return el;
251
+ }
252
+
253
+ function isEditableTextContainer(el: HTMLElement): boolean {
254
+ if (!el.textContent?.trim()) return false;
255
+ return hasOnlyInlineTextChildren(el);
256
+ }
257
+
258
+ function hasOnlyInlineTextChildren(el: HTMLElement): boolean {
259
+ for (const child of Array.from(el.childNodes)) {
260
+ if (child.nodeType === Node.TEXT_NODE) {
261
+ continue;
262
+ } else if (child instanceof HTMLElement) {
263
+ if (child.tagName === 'BR') continue;
264
+ if (INLINE_TEXT_TAGS.has(child.tagName) && hasOnlyInlineTextChildren(child)) continue;
265
+ }
266
+ return false;
267
+ }
268
+ return true;
269
+ }