@plures/design-dojo 0.5.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/dist/app/CanvasBreadcrumb.svelte +146 -0
  2. package/dist/app/CanvasBreadcrumb.svelte.d.ts +19 -0
  3. package/dist/app/CanvasBreadcrumb.types.js +1 -0
  4. package/dist/app/ChatInput.svelte +296 -0
  5. package/dist/app/ChatInput.svelte.d.ts +15 -0
  6. package/dist/app/ChatView.svelte +542 -0
  7. package/dist/app/ChatView.svelte.d.ts +19 -0
  8. package/dist/app/ChatView.types.js +1 -0
  9. package/dist/app/ConversationGraph.svelte +471 -0
  10. package/dist/app/ConversationGraph.svelte.d.ts +20 -0
  11. package/dist/app/FirstRunWizard.svelte +542 -0
  12. package/dist/app/FirstRunWizard.svelte.d.ts +10 -0
  13. package/dist/app/FirstRunWizard.types.js +1 -0
  14. package/dist/app/MemorySidebar.svelte +258 -0
  15. package/dist/app/MemorySidebar.svelte.d.ts +9 -0
  16. package/dist/app/MemorySidebar.types.js +1 -0
  17. package/dist/app/PeerStatusPanel.svelte +464 -0
  18. package/dist/app/PeerStatusPanel.svelte.d.ts +16 -0
  19. package/dist/app/ProcedureCanvas.svelte +994 -0
  20. package/dist/app/ProcedureCanvas.svelte.d.ts +12 -0
  21. package/dist/app/ProcedureCanvas.types.js +1 -0
  22. package/dist/app/ProcedureEditor.svelte +494 -0
  23. package/dist/app/ProcedureEditor.svelte.d.ts +11 -0
  24. package/dist/app/ProcedureEditor.types.js +1 -0
  25. package/dist/app/ProcedureInspector.svelte +520 -0
  26. package/dist/app/ProcedureInspector.svelte.d.ts +15 -0
  27. package/dist/app/ProcedureNode.svelte +283 -0
  28. package/dist/app/ProcedureNode.svelte.d.ts +26 -0
  29. package/dist/app/Realm.types.js +1 -0
  30. package/dist/app/RealmIndicator.svelte +81 -0
  31. package/dist/app/RealmIndicator.svelte.d.ts +10 -0
  32. package/dist/app/RealmSwitcher.svelte +354 -0
  33. package/dist/app/RealmSwitcher.svelte.d.ts +16 -0
  34. package/dist/app/SemanticConversation.types.js +1 -0
  35. package/dist/app/SemanticSearchInput.svelte +630 -0
  36. package/dist/app/SemanticSearchInput.svelte.d.ts +20 -0
  37. package/dist/app/SemanticSearchInput.types.js +1 -0
  38. package/dist/app/SemanticTimeline.svelte +426 -0
  39. package/dist/app/SemanticTimeline.svelte.d.ts +13 -0
  40. package/dist/app/SettingsPanel.svelte +330 -0
  41. package/dist/app/SettingsPanel.svelte.d.ts +12 -0
  42. package/dist/app/SettingsPanel.types.js +1 -0
  43. package/dist/app/SubCanvas.svelte +457 -0
  44. package/dist/app/SubCanvas.svelte.d.ts +48 -0
  45. package/dist/app/SubCanvas.types.js +1 -0
  46. package/dist/app/Sync.types.js +1 -0
  47. package/dist/app/SyncIndicator.svelte +219 -0
  48. package/dist/app/SyncIndicator.svelte.d.ts +15 -0
  49. package/dist/app/SyncTimeline.svelte +299 -0
  50. package/dist/app/SyncTimeline.svelte.d.ts +12 -0
  51. package/dist/app/TagCloud.svelte +287 -0
  52. package/dist/app/TagCloud.svelte.d.ts +12 -0
  53. package/dist/app/WorkModeToggle.svelte +188 -0
  54. package/dist/app/WorkModeToggle.svelte.d.ts +17 -0
  55. package/dist/data/List.svelte +104 -0
  56. package/dist/data/List.svelte.d.ts +18 -0
  57. package/dist/data/ListItem.svelte +130 -0
  58. package/dist/data/ListItem.svelte.d.ts +12 -0
  59. package/dist/data/Table.svelte +241 -0
  60. package/dist/data/Table.svelte.d.ts +17 -0
  61. package/dist/data/index.d.ts +3 -0
  62. package/dist/data/index.js +3 -0
  63. package/dist/disclosure/Accordion.svelte +48 -0
  64. package/dist/disclosure/Accordion.svelte.d.ts +15 -0
  65. package/dist/feedback/Badge.svelte +60 -0
  66. package/dist/feedback/Badge.svelte.d.ts +14 -0
  67. package/dist/feedback/Callout.svelte +52 -0
  68. package/dist/feedback/Callout.svelte.d.ts +12 -0
  69. package/dist/feedback/EmptyState.svelte +47 -0
  70. package/dist/feedback/EmptyState.svelte.d.ts +12 -0
  71. package/dist/feedback/ProgressBar.svelte +95 -0
  72. package/dist/feedback/ProgressBar.svelte.d.ts +16 -0
  73. package/dist/forms/FileUpload.svelte +99 -0
  74. package/dist/forms/FileUpload.svelte.d.ts +18 -0
  75. package/dist/forms/RadioGroup.svelte +84 -0
  76. package/dist/forms/RadioGroup.svelte.d.ts +19 -0
  77. package/dist/icons/NerdFont.svelte +44 -0
  78. package/dist/icons/NerdFont.svelte.d.ts +13 -0
  79. package/dist/icons/index.d.ts +1 -0
  80. package/dist/icons/index.js +1 -0
  81. package/dist/index.d.ts +76 -0
  82. package/dist/index.js +70 -6212
  83. package/dist/layout/Box.svelte +207 -0
  84. package/dist/layout/Box.svelte.d.ts +22 -0
  85. package/dist/layout/Sidebar.svelte +210 -0
  86. package/dist/layout/Sidebar.svelte.d.ts +22 -0
  87. package/dist/layout/SplitPane.svelte +64 -0
  88. package/dist/layout/SplitPane.svelte.d.ts +12 -0
  89. package/dist/layout/StatusBar.svelte +83 -0
  90. package/dist/layout/StatusBar.svelte.d.ts +12 -0
  91. package/dist/layout/StatusBarItem.svelte +146 -0
  92. package/dist/layout/StatusBarItem.svelte.d.ts +15 -0
  93. package/dist/layout/StatusBarSpacer.svelte +38 -0
  94. package/dist/layout/StatusBarSpacer.svelte.d.ts +3 -0
  95. package/dist/layout/Tabs.svelte +254 -0
  96. package/dist/layout/Tabs.svelte.d.ts +21 -0
  97. package/dist/layout/Tabs.types.js +1 -0
  98. package/dist/layout/TitleBar.svelte +422 -0
  99. package/dist/layout/TitleBar.svelte.d.ts +22 -0
  100. package/dist/layout/index.d.ts +9 -0
  101. package/dist/layout/index.js +8 -0
  102. package/dist/motion/index.d.ts +1 -0
  103. package/dist/motion/index.js +1 -0
  104. package/dist/motion/spring.js +116 -0
  105. package/dist/overlays/ContextMenu.svelte +268 -0
  106. package/dist/overlays/ContextMenu.svelte.d.ts +17 -0
  107. package/dist/overlays/Dialog.svelte +264 -0
  108. package/dist/overlays/Dialog.svelte.d.ts +20 -0
  109. package/dist/overlays/Menu.svelte +274 -0
  110. package/dist/overlays/Menu.svelte.d.ts +26 -0
  111. package/dist/overlays/Menu.types.js +1 -0
  112. package/dist/overlays/Popover.svelte +158 -0
  113. package/dist/overlays/Popover.svelte.d.ts +21 -0
  114. package/dist/overlays/Toast.svelte +179 -0
  115. package/dist/overlays/Toast.svelte.d.ts +19 -0
  116. package/dist/overlays/Tooltip.svelte +114 -0
  117. package/dist/overlays/Tooltip.svelte.d.ts +17 -0
  118. package/dist/overlays/index.d.ts +7 -0
  119. package/dist/overlays/index.js +6 -0
  120. package/dist/primitives/Button.svelte +217 -0
  121. package/dist/primitives/Button.svelte.d.ts +13 -0
  122. package/dist/primitives/ContextMenu.svelte +242 -0
  123. package/dist/primitives/ContextMenu.svelte.d.ts +18 -0
  124. package/dist/primitives/ContextMenu.types.js +1 -0
  125. package/dist/primitives/Input.svelte +468 -0
  126. package/dist/primitives/Input.svelte.d.ts +21 -0
  127. package/dist/primitives/MarkdownEditor.svelte +781 -0
  128. package/dist/primitives/MarkdownEditor.svelte.d.ts +21 -0
  129. package/dist/primitives/MarkdownEditor.types.js +1 -0
  130. package/dist/primitives/SearchInput.svelte +623 -0
  131. package/dist/primitives/SearchInput.svelte.d.ts +24 -0
  132. package/dist/primitives/Select.svelte +336 -0
  133. package/dist/primitives/Select.svelte.d.ts +18 -0
  134. package/dist/primitives/Text.svelte +177 -0
  135. package/dist/primitives/Text.svelte.d.ts +26 -0
  136. package/dist/primitives/Toggle.svelte +138 -0
  137. package/dist/primitives/Toggle.svelte.d.ts +9 -0
  138. package/dist/primitives/index.d.ts +9 -0
  139. package/dist/primitives/index.js +7 -0
  140. package/dist/primitives/search-input-types.js +1 -0
  141. package/dist/surfaces/ChatPane.svelte +520 -0
  142. package/dist/surfaces/ChatPane.svelte.d.ts +15 -0
  143. package/dist/surfaces/ChatPane.types.js +1 -0
  144. package/dist/surfaces/GlassPanel.svelte +118 -0
  145. package/dist/surfaces/GlassPanel.svelte.d.ts +19 -0
  146. package/dist/surfaces/Pane.svelte +172 -0
  147. package/dist/surfaces/Pane.svelte.d.ts +25 -0
  148. package/dist/surfaces/index.d.ts +4 -0
  149. package/dist/surfaces/index.js +3 -0
  150. package/dist/telemetry/correlation.js +26 -0
  151. package/dist/telemetry/index.d.ts +4 -4
  152. package/dist/telemetry/index.js +20 -101
  153. package/dist/telemetry/sampling.js +58 -0
  154. package/dist/telemetry/tracer.d.ts +16 -1
  155. package/dist/telemetry/tracer.js +112 -0
  156. package/dist/tokens.css +123 -0
  157. package/dist/tui-tokens.css +36 -0
  158. package/dist/useTui.js +31 -0
  159. package/package.json +32 -22
  160. package/dist/design-dojo.css +0 -1
  161. package/dist/enforce/index.d.ts +0 -75
  162. package/dist/enforce/known-components.d.ts +0 -7
  163. package/dist/enforce/rules/no-local-components.d.ts +0 -29
  164. package/dist/enforce/rules/prefer-design-dojo-imports.d.ts +0 -27
  165. package/dist/enforce.js +0 -132
  166. package/dist/lib/app/CanvasBreadcrumb.svelte.d.ts +0 -1
  167. package/dist/lib/app/ChatInput.svelte.d.ts +0 -1
  168. package/dist/lib/app/ChatView.svelte.d.ts +0 -1
  169. package/dist/lib/app/ConversationGraph.svelte.d.ts +0 -1
  170. package/dist/lib/app/FirstRunWizard.svelte.d.ts +0 -1
  171. package/dist/lib/app/MemorySidebar.svelte.d.ts +0 -1
  172. package/dist/lib/app/PeerStatusPanel.svelte.d.ts +0 -1
  173. package/dist/lib/app/ProcedureCanvas.svelte.d.ts +0 -1
  174. package/dist/lib/app/ProcedureEditor.svelte.d.ts +0 -1
  175. package/dist/lib/app/ProcedureInspector.svelte.d.ts +0 -1
  176. package/dist/lib/app/ProcedureNode.svelte.d.ts +0 -1
  177. package/dist/lib/app/RealmIndicator.svelte.d.ts +0 -1
  178. package/dist/lib/app/RealmSwitcher.svelte.d.ts +0 -1
  179. package/dist/lib/app/SemanticSearchInput.svelte.d.ts +0 -1
  180. package/dist/lib/app/SemanticTimeline.svelte.d.ts +0 -1
  181. package/dist/lib/app/SettingsPanel.svelte.d.ts +0 -1
  182. package/dist/lib/app/SubCanvas.svelte.d.ts +0 -1
  183. package/dist/lib/app/SyncIndicator.svelte.d.ts +0 -1
  184. package/dist/lib/app/SyncTimeline.svelte.d.ts +0 -1
  185. package/dist/lib/app/TagCloud.svelte.d.ts +0 -1
  186. package/dist/lib/app/WorkModeToggle.svelte.d.ts +0 -1
  187. package/dist/lib/data/List.svelte.d.ts +0 -1
  188. package/dist/lib/data/ListItem.svelte.d.ts +0 -1
  189. package/dist/lib/data/Table.svelte.d.ts +0 -1
  190. package/dist/lib/data/index.d.ts +0 -3
  191. package/dist/lib/disclosure/Accordion.svelte.d.ts +0 -1
  192. package/dist/lib/feedback/Badge.svelte.d.ts +0 -1
  193. package/dist/lib/feedback/Callout.svelte.d.ts +0 -1
  194. package/dist/lib/feedback/EmptyState.svelte.d.ts +0 -1
  195. package/dist/lib/feedback/ProgressBar.svelte.d.ts +0 -1
  196. package/dist/lib/forms/FileUpload.svelte.d.ts +0 -1
  197. package/dist/lib/forms/RadioGroup.svelte.d.ts +0 -1
  198. package/dist/lib/icons/NerdFont.svelte.d.ts +0 -1
  199. package/dist/lib/icons/index.d.ts +0 -1
  200. package/dist/lib/index.d.ts +0 -76
  201. package/dist/lib/layout/Box.svelte.d.ts +0 -1
  202. package/dist/lib/layout/Sidebar.svelte.d.ts +0 -1
  203. package/dist/lib/layout/SplitPane.svelte.d.ts +0 -1
  204. package/dist/lib/layout/StatusBar.svelte.d.ts +0 -1
  205. package/dist/lib/layout/StatusBarItem.svelte.d.ts +0 -1
  206. package/dist/lib/layout/StatusBarSpacer.svelte.d.ts +0 -1
  207. package/dist/lib/layout/Tabs.svelte.d.ts +0 -1
  208. package/dist/lib/layout/TitleBar.svelte.d.ts +0 -1
  209. package/dist/lib/layout/index.d.ts +0 -9
  210. package/dist/lib/motion/index.d.ts +0 -1
  211. package/dist/lib/overlays/ContextMenu.svelte.d.ts +0 -1
  212. package/dist/lib/overlays/Dialog.svelte.d.ts +0 -1
  213. package/dist/lib/overlays/Menu.svelte.d.ts +0 -1
  214. package/dist/lib/overlays/Popover.svelte.d.ts +0 -1
  215. package/dist/lib/overlays/Toast.svelte.d.ts +0 -1
  216. package/dist/lib/overlays/Tooltip.svelte.d.ts +0 -1
  217. package/dist/lib/overlays/index.d.ts +0 -7
  218. package/dist/lib/primitives/Button.svelte.d.ts +0 -1
  219. package/dist/lib/primitives/ContextMenu.svelte.d.ts +0 -1
  220. package/dist/lib/primitives/Input.svelte.d.ts +0 -1
  221. package/dist/lib/primitives/MarkdownEditor.svelte.d.ts +0 -1
  222. package/dist/lib/primitives/SearchInput.svelte.d.ts +0 -1
  223. package/dist/lib/primitives/Select.svelte.d.ts +0 -1
  224. package/dist/lib/primitives/Text.svelte.d.ts +0 -1
  225. package/dist/lib/primitives/Toggle.svelte.d.ts +0 -1
  226. package/dist/lib/primitives/index.d.ts +0 -9
  227. package/dist/lib/surfaces/ChatPane.svelte.d.ts +0 -1
  228. package/dist/lib/surfaces/GlassPanel.svelte.d.ts +0 -1
  229. package/dist/lib/surfaces/Pane.svelte.d.ts +0 -1
  230. package/dist/lib/surfaces/index.d.ts +0 -4
  231. /package/dist/{lib/app → app}/CanvasBreadcrumb.types.d.ts +0 -0
  232. /package/dist/{lib/app → app}/ChatView.types.d.ts +0 -0
  233. /package/dist/{lib/app → app}/FirstRunWizard.types.d.ts +0 -0
  234. /package/dist/{lib/app → app}/MemorySidebar.types.d.ts +0 -0
  235. /package/dist/{lib/app → app}/ProcedureCanvas.types.d.ts +0 -0
  236. /package/dist/{lib/app → app}/ProcedureEditor.types.d.ts +0 -0
  237. /package/dist/{lib/app → app}/Realm.types.d.ts +0 -0
  238. /package/dist/{lib/app → app}/SemanticConversation.types.d.ts +0 -0
  239. /package/dist/{lib/app → app}/SemanticSearchInput.types.d.ts +0 -0
  240. /package/dist/{lib/app → app}/SettingsPanel.types.d.ts +0 -0
  241. /package/dist/{lib/app → app}/SubCanvas.types.d.ts +0 -0
  242. /package/dist/{lib/app → app}/Sync.types.d.ts +0 -0
  243. /package/dist/{lib/layout → layout}/Tabs.types.d.ts +0 -0
  244. /package/dist/{lib/motion → motion}/spring.d.ts +0 -0
  245. /package/dist/{lib/overlays → overlays}/Menu.types.d.ts +0 -0
  246. /package/dist/{lib/primitives → primitives}/ContextMenu.types.d.ts +0 -0
  247. /package/dist/{lib/primitives → primitives}/MarkdownEditor.types.d.ts +0 -0
  248. /package/dist/{lib/primitives → primitives}/search-input-types.d.ts +0 -0
  249. /package/dist/{lib/surfaces → surfaces}/ChatPane.types.d.ts +0 -0
  250. /package/dist/{lib/useTui.d.ts → useTui.d.ts} +0 -0
@@ -0,0 +1,781 @@
1
+ <!--
2
+ MarkdownEditor — Simple embeddable markdown editor control.
3
+
4
+ Features:
5
+ - GUI mode: resizable textarea with optional live preview panel
6
+ - TUI mode: box-drawing border multi-line text area
7
+ - Monospace font option for code-heavy content
8
+ - Preview toggle: "edit" | "preview" | "split"
9
+ - Keyboard shortcuts: Ctrl+B bold, Ctrl+I italic, Ctrl+` inline code
10
+ - Safe built-in markdown renderer (HTML-escaped, no external deps)
11
+ - Value binding ($bindable), placeholder, label
12
+ - Disabled state
13
+ - Events: onchange
14
+ -->
15
+ <script lang="ts">
16
+ import { useTui } from "../useTui.js";
17
+ import type { MarkdownEditorMode } from "./MarkdownEditor.types.js";
18
+
19
+ interface Props {
20
+ tui?: boolean;
21
+ value?: string;
22
+ placeholder?: string;
23
+ label?: string;
24
+ /** Use monospace font in editor area (good for code/snippets). */
25
+ monospace?: boolean;
26
+ /** Show/hide the preview toggle toolbar. Default: true */
27
+ showToolbar?: boolean;
28
+ /** Active view mode. Default: "edit" */
29
+ mode?: MarkdownEditorMode;
30
+ disabled?: boolean;
31
+ /** Minimum rows for the textarea. Default: 6 */
32
+ rows?: number;
33
+ class?: string;
34
+ onchange?: (value: string) => void;
35
+ }
36
+
37
+ let {
38
+ tui = false,
39
+ value = $bindable(""),
40
+ placeholder = "",
41
+ label = "",
42
+ monospace = false,
43
+ showToolbar = true,
44
+ mode = $bindable<MarkdownEditorMode>("edit"),
45
+ disabled = false,
46
+ rows = 6,
47
+ class: className = "",
48
+ onchange,
49
+ }: Props = $props();
50
+
51
+ const getTuiCtx = useTui();
52
+ const isTui = $derived(tui || getTuiCtx());
53
+
54
+ let textareaEl: HTMLTextAreaElement | undefined = $state();
55
+ let isFocused = $state(false);
56
+
57
+ // Stable id for associating the <label> with the <textarea>
58
+ const editorId = `md-editor-${Math.random().toString(36).slice(2, 9)}`;
59
+
60
+ // TUI cannot render a split view; coerce "split" → "edit" when in TUI mode
61
+ $effect(() => {
62
+ if (isTui && mode === "split") {
63
+ mode = "edit";
64
+ }
65
+ });
66
+
67
+ // ── Markdown renderer ────────────────────────────────────────────────────────
68
+
69
+ /** Escape HTML entities to prevent XSS before injecting markdown HTML. */
70
+ function escapeHtml(str: string): string {
71
+ return str
72
+ .replace(/&/g, "&amp;")
73
+ .replace(/</g, "&lt;")
74
+ .replace(/>/g, "&gt;")
75
+ .replace(/"/g, "&quot;")
76
+ .replace(/'/g, "&#39;");
77
+ }
78
+
79
+ /**
80
+ * Decode a minimal set of HTML entities, including numeric ones, so that
81
+ * obfuscated protocols like "javascript&#58;" are normalized before checks.
82
+ */
83
+ function decodeHtmlEntities(str: string): string {
84
+ // First handle the common named entities we use elsewhere.
85
+ let result = str
86
+ .replace(/&amp;/g, "&")
87
+ .replace(/&lt;/g, "<")
88
+ .replace(/&gt;/g, ">")
89
+ .replace(/&quot;/g, '"')
90
+ .replace(/&#39;/g, "'");
91
+
92
+ // Then handle numeric (decimal and hex) character references.
93
+ result = result.replace(/&#(x?[0-9a-fA-F]+);?/g, (match, num) => {
94
+ try {
95
+ const codePoint =
96
+ typeof num === "string" && num.toLowerCase().startsWith("x")
97
+ ? parseInt(num.slice(1), 16)
98
+ : parseInt(num, 10);
99
+ if (!Number.isFinite(codePoint) || codePoint <= 0) {
100
+ return match;
101
+ }
102
+ return String.fromCodePoint(codePoint);
103
+ } catch {
104
+ return match;
105
+ }
106
+ });
107
+
108
+ return result;
109
+ }
110
+
111
+ /** Allow only safe URL protocols; return empty string for dangerous ones. */
112
+ function sanitizeHref(href: string): string {
113
+ const trimmed = href.trim();
114
+ if (trimmed === "") {
115
+ return "";
116
+ }
117
+
118
+ const decoded = decodeHtmlEntities(trimmed);
119
+
120
+ // Allow fragment-only links like "#section".
121
+ if (decoded.startsWith("#")) {
122
+ return decoded;
123
+ }
124
+
125
+ const allowedSchemes = new Set(["http", "https", "mailto"]);
126
+
127
+ try {
128
+ // Use a benign base so relative URLs can still be parsed.
129
+ const url = new URL(decoded, "http://example.com");
130
+ const protocol = url.protocol.replace(":", "").toLowerCase();
131
+
132
+ // If the URL has an explicit protocol, enforce the allow-list.
133
+ if (protocol && protocol !== "http") {
134
+ // If a non-http(s)/mailto protocol is present, only allow if explicitly whitelisted.
135
+ return allowedSchemes.has(protocol) ? decoded : "";
136
+ }
137
+
138
+ // For http/https (including relative URLs resolved against the base), allow.
139
+ return decoded;
140
+ } catch {
141
+ // If parsing fails but the string looks like it starts with a scheme,
142
+ // reject it; otherwise treat it as a relative URL and allow.
143
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(decoded)) {
144
+ return "";
145
+ }
146
+ return decoded;
147
+ }
148
+ }
149
+
150
+ /** Returns true when a line should not be accumulated into a paragraph. */
151
+ function isSpecialMarkdownLine(line: string): boolean {
152
+ return (
153
+ line.trim() === "" ||
154
+ line.startsWith("#") ||
155
+ line.startsWith("```") ||
156
+ /^[ \t]*[-*+] /.test(line) ||
157
+ /^[ \t]*\d+\. /.test(line) ||
158
+ /^[-*_]{3,}$/.test(line.trim())
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Minimal safe markdown renderer.
164
+ * Processes: headings, bold, italic, inline code, code blocks,
165
+ * unordered lists, ordered lists, horizontal rules, links, paragraphs.
166
+ * All user content is HTML-escaped before formatting is applied.
167
+ */
168
+ function renderMarkdown(src: string): string {
169
+ if (!src) return "";
170
+
171
+ const lines = src.split("\n");
172
+ const output: string[] = [];
173
+ let i = 0;
174
+
175
+ while (i < lines.length) {
176
+ const line = lines[i];
177
+
178
+ // Fenced code block
179
+ if (line.startsWith("```")) {
180
+ const lang = escapeHtml(line.slice(3).trim());
181
+ const codeLines: string[] = [];
182
+ i++;
183
+ while (i < lines.length && !lines[i].startsWith("```")) {
184
+ codeLines.push(escapeHtml(lines[i]));
185
+ i++;
186
+ }
187
+ const langAttr = lang ? ` class="language-${lang}"` : "";
188
+ output.push(`<pre><code${langAttr}>${codeLines.join("\n")}</code></pre>`);
189
+ i++; // skip closing ```
190
+ continue;
191
+ }
192
+
193
+ // Unordered list
194
+ if (/^[ \t]*[-*+] /.test(line)) {
195
+ const items: string[] = [];
196
+ while (i < lines.length && /^[ \t]*[-*+] /.test(lines[i])) {
197
+ items.push(`<li>${inlineMarkdown(lines[i].replace(/^[ \t]*[-*+] /, ""))}</li>`);
198
+ i++;
199
+ }
200
+ output.push(`<ul>${items.join("")}</ul>`);
201
+ continue;
202
+ }
203
+
204
+ // Ordered list
205
+ if (/^[ \t]*\d+\. /.test(line)) {
206
+ const items: string[] = [];
207
+ while (i < lines.length && /^[ \t]*\d+\. /.test(lines[i])) {
208
+ items.push(`<li>${inlineMarkdown(lines[i].replace(/^[ \t]*\d+\. /, ""))}</li>`);
209
+ i++;
210
+ }
211
+ output.push(`<ol>${items.join("")}</ol>`);
212
+ continue;
213
+ }
214
+
215
+ // Horizontal rule
216
+ if (/^[-*_]{3,}$/.test(line.trim())) {
217
+ output.push("<hr>");
218
+ i++;
219
+ continue;
220
+ }
221
+
222
+ // ATX headings
223
+ const headingMatch = line.match(/^(#{1,6}) (.*)/);
224
+ if (headingMatch) {
225
+ const level = headingMatch[1].length;
226
+ output.push(`<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`);
227
+ i++;
228
+ continue;
229
+ }
230
+
231
+ // Blank line → paragraph break
232
+ if (line.trim() === "") {
233
+ output.push("");
234
+ i++;
235
+ continue;
236
+ }
237
+
238
+ // Paragraph: collect consecutive non-blank lines
239
+ const paraLines: string[] = [];
240
+ while (i < lines.length && !isSpecialMarkdownLine(lines[i])) {
241
+ paraLines.push(lines[i]);
242
+ i++;
243
+ }
244
+ if (paraLines.length) {
245
+ output.push(`<p>${inlineMarkdown(paraLines.join(" "))}</p>`);
246
+ }
247
+ }
248
+
249
+ return output.join("\n");
250
+ }
251
+
252
+ /** Apply inline markdown formatting to an already HTML-safe string. */
253
+ function inlineMarkdown(raw: string): string {
254
+ // Escape HTML first
255
+ let s = escapeHtml(raw);
256
+ // Inline code (must run before bold/italic to protect backtick content)
257
+ s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
258
+ // Bold
259
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
260
+ s = s.replace(/__([^_]+)__/g, "<strong>$1</strong>");
261
+ // Italic
262
+ s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
263
+ s = s.replace(/_([^_]+)_/g, "<em>$1</em>");
264
+ // Links — href sanitized to block javascript:/vbscript:/data: URLs
265
+ // text is already HTML-escaped (part of `s`); use it directly to avoid double-escaping.
266
+ // safe href is escaped again for attribute safety (prevents quote-injection XSS).
267
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, href) => {
268
+ const safe = sanitizeHref(href);
269
+ if (!safe) return text;
270
+ return `<a href="${escapeHtml(safe)}" target="_blank" rel="noopener noreferrer">${text}</a>`;
271
+ });
272
+ return s;
273
+ }
274
+
275
+ const previewHtml = $derived(renderMarkdown(value ?? ""));
276
+
277
+ // ── Keyboard shortcuts ───────────────────────────────────────────────────────
278
+
279
+ function wrapSelection(before: string, after: string) {
280
+ const el = textareaEl;
281
+ if (!el || disabled) return;
282
+ const start = el.selectionStart ?? 0;
283
+ const end = el.selectionEnd ?? 0;
284
+ const selected = (value ?? "").slice(start, end);
285
+ const newValue =
286
+ (value ?? "").slice(0, start) +
287
+ before +
288
+ selected +
289
+ after +
290
+ (value ?? "").slice(end);
291
+ value = newValue;
292
+ onchange?.(newValue);
293
+ // Restore selection inside the inserted markers
294
+ requestAnimationFrame(() => {
295
+ el.selectionStart = start + before.length;
296
+ el.selectionEnd = end + before.length;
297
+ el.focus();
298
+ });
299
+ }
300
+
301
+ function handleKeydown(e: KeyboardEvent) {
302
+ const ctrl = e.ctrlKey || e.metaKey;
303
+ if (ctrl && e.key === "b") {
304
+ e.preventDefault();
305
+ wrapSelection("**", "**");
306
+ } else if (ctrl && e.key === "i") {
307
+ e.preventDefault();
308
+ wrapSelection("*", "*");
309
+ } else if (ctrl && e.key === "`") {
310
+ e.preventDefault();
311
+ wrapSelection("`", "`");
312
+ }
313
+ }
314
+
315
+ function handleInput(e: Event) {
316
+ const target = e.target as HTMLTextAreaElement;
317
+ value = target.value;
318
+ onchange?.(value);
319
+ }
320
+
321
+ // ── TUI helpers ──────────────────────────────────────────────────────────────
322
+
323
+ const isVisuallyFocused = $derived(isFocused);
324
+
325
+ const tuiTopBorder = $derived(
326
+ label
327
+ ? (() => {
328
+ const maxLabelLen = 30;
329
+ const safeLabel = label.length > maxLabelLen ? label.substring(0, maxLabelLen) : label;
330
+ const section = `─ ${safeLabel} `;
331
+ const fill = Math.max(0, 38 - section.length);
332
+ return `┌${section}${"─".repeat(fill)}┐`;
333
+ })()
334
+ : `┌${"─".repeat(38)}┐`,
335
+ );
336
+
337
+ const tuiBottomBorder = `└${"─".repeat(38)}┘`;
338
+
339
+ const tuiModeIndicator = $derived(
340
+ isTui && showToolbar
341
+ ? ` [${mode === "preview" ? "PREV" : "EDIT"}]`
342
+ : "",
343
+ );
344
+ </script>
345
+
346
+ {#if isTui}
347
+ <!-- TUI mode: box-drawing border around a real textarea -->
348
+ <div
349
+ class="tui-md {className}"
350
+ class:focused={isVisuallyFocused}
351
+ class:disabled
352
+ >
353
+ <div class="tui-top" class:focused={isVisuallyFocused}>{tuiTopBorder}{tuiModeIndicator}</div>
354
+
355
+ {#if mode !== "preview"}
356
+ <div class="tui-row">
357
+ <span class="tui-side" class:focused={isVisuallyFocused}>│</span>
358
+ <textarea
359
+ bind:this={textareaEl}
360
+ class="tui-textarea"
361
+ class:monospace
362
+ {rows}
363
+ {disabled}
364
+ {value}
365
+ aria-label={label || placeholder || "Markdown editor"}
366
+ aria-multiline="true"
367
+ oninput={handleInput}
368
+ onkeydown={handleKeydown}
369
+ onfocus={() => (isFocused = true)}
370
+ onblur={() => (isFocused = false)}
371
+ ></textarea>
372
+ <span class="tui-side" class:focused={isVisuallyFocused}>│</span>
373
+ </div>
374
+ {/if}
375
+
376
+ {#if mode === "preview"}
377
+ <!-- TUI preview intentionally shows raw markdown text; HTML cannot be rendered in terminal environments -->
378
+ <div class="tui-row">
379
+ <span class="tui-side" class:focused={isVisuallyFocused}>│</span>
380
+ <div class="tui-preview" aria-label="Markdown preview">
381
+ {value ? value.split("\n").slice(0, rows).join("\n") : (placeholder || "No content")}
382
+ </div>
383
+ <span class="tui-side" class:focused={isVisuallyFocused}>│</span>
384
+ </div>
385
+ {/if}
386
+
387
+ <div class="tui-bottom" class:focused={isVisuallyFocused}>{tuiBottomBorder}</div>
388
+
389
+ {#if showToolbar}
390
+ <div class="tui-toolbar">
391
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
392
+ <span
393
+ role="button"
394
+ tabindex={disabled ? -1 : 0}
395
+ class="tui-tab"
396
+ class:active={mode === "edit"}
397
+ onclick={() => { mode = "edit"; }}
398
+ onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") mode = "edit"; }}
399
+ aria-pressed={mode === "edit"}
400
+ >EDIT</span>
401
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
402
+ <span
403
+ role="button"
404
+ tabindex={disabled ? -1 : 0}
405
+ class="tui-tab"
406
+ class:active={mode === "preview"}
407
+ onclick={() => { mode = "preview"; }}
408
+ onkeydown={(e) => { if (e.key === "Enter" || e.key === " ") mode = "preview"; }}
409
+ aria-pressed={mode === "preview"}
410
+ >PREV</span>
411
+ </div>
412
+ {/if}
413
+ </div>
414
+ {:else}
415
+ <!-- GUI mode -->
416
+ <div
417
+ class="md-editor {className}"
418
+ class:focused={isVisuallyFocused}
419
+ class:disabled
420
+ class:split={mode === "split"}
421
+ >
422
+ {#if label}
423
+ <label class="md-label" for={editorId}>{label}</label>
424
+ {/if}
425
+
426
+ {#if showToolbar}
427
+ <div class="md-toolbar" role="toolbar" aria-label="Editor mode">
428
+ <button
429
+ class="md-tab"
430
+ class:active={mode === "edit"}
431
+ type="button"
432
+ {disabled}
433
+ onclick={() => (mode = "edit")}
434
+ aria-pressed={mode === "edit"}
435
+ >Edit</button>
436
+ <button
437
+ class="md-tab"
438
+ class:active={mode === "split"}
439
+ type="button"
440
+ {disabled}
441
+ onclick={() => (mode = "split")}
442
+ aria-pressed={mode === "split"}
443
+ >Split</button>
444
+ <button
445
+ class="md-tab"
446
+ class:active={mode === "preview"}
447
+ type="button"
448
+ {disabled}
449
+ onclick={() => (mode = "preview")}
450
+ aria-pressed={mode === "preview"}
451
+ >Preview</button>
452
+
453
+ <span class="md-toolbar-hint" aria-label="Keyboard shortcuts: Ctrl+B bold, Ctrl+I italic, Ctrl+backtick code">Ctrl+B bold · Ctrl+I italic · Ctrl+` code</span>
454
+ </div>
455
+ {/if}
456
+
457
+ <div class="md-body" class:split={mode === "split"}>
458
+ {#if mode !== "preview"}
459
+ <textarea
460
+ bind:this={textareaEl}
461
+ id={editorId}
462
+ class="md-textarea"
463
+ class:monospace
464
+ {rows}
465
+ {disabled}
466
+ {placeholder}
467
+ aria-label={label ? undefined : "Markdown editor"}
468
+ aria-multiline="true"
469
+ value={value}
470
+ oninput={handleInput}
471
+ onkeydown={handleKeydown}
472
+ onfocus={() => (isFocused = true)}
473
+ onblur={() => (isFocused = false)}
474
+ ></textarea>
475
+ {/if}
476
+
477
+ {#if mode !== "edit"}
478
+ <div
479
+ class="md-preview"
480
+ class:full={mode === "preview"}
481
+ aria-label="Markdown preview"
482
+ aria-live="polite"
483
+ >
484
+ {#if value}
485
+ <!-- renderMarkdown HTML-escapes all user input before injecting markdown tags -->
486
+ {@html previewHtml}
487
+ {:else}
488
+ <span class="md-preview-empty">{placeholder || "Nothing to preview"}</span>
489
+ {/if}
490
+ </div>
491
+ {/if}
492
+ </div>
493
+ </div>
494
+ {/if}
495
+
496
+ <style>
497
+ /* ===== GUI mode ===== */
498
+ .md-editor {
499
+ display: flex;
500
+ flex-direction: column;
501
+ width: 100%;
502
+ border: 1.5px solid var(--color-border, #2a2a2a);
503
+ border-radius: var(--radius-md, 10px);
504
+ background: var(--surface-2, #1e1e1e);
505
+ overflow: hidden;
506
+ transition: border-color 0.15s ease;
507
+ }
508
+
509
+ .md-editor.focused {
510
+ border-color: var(--color-accent, #6366f1);
511
+ }
512
+
513
+ .md-editor.disabled {
514
+ opacity: 0.4;
515
+ pointer-events: none;
516
+ }
517
+
518
+ .md-label {
519
+ padding: var(--space-2, 8px) var(--space-3, 12px) 0;
520
+ font-size: var(--text-xs, 12px);
521
+ color: var(--color-text-muted, #888888);
522
+ }
523
+
524
+ .md-toolbar {
525
+ display: flex;
526
+ align-items: center;
527
+ gap: var(--space-1, 4px);
528
+ padding: var(--space-1, 4px) var(--space-2, 8px);
529
+ border-bottom: 1px solid var(--color-border, #2a2a2a);
530
+ background: var(--surface-1, #161616);
531
+ }
532
+
533
+ .md-tab {
534
+ padding: 2px var(--space-2, 8px);
535
+ background: transparent;
536
+ border: 1px solid transparent;
537
+ border-radius: var(--radius-sm, 6px);
538
+ color: var(--color-text-muted, #888888);
539
+ font-size: var(--text-xs, 12px);
540
+ cursor: pointer;
541
+ transition: color 0.1s, border-color 0.1s, background 0.1s;
542
+ }
543
+
544
+ .md-tab:hover {
545
+ color: var(--color-text, #e8e8e8);
546
+ background: var(--surface-2, #1e1e1e);
547
+ }
548
+
549
+ .md-tab.active {
550
+ color: var(--color-accent, #6366f1);
551
+ border-color: var(--color-accent, #6366f1);
552
+ background: color-mix(in srgb, var(--color-accent, #6366f1) 10%, transparent);
553
+ }
554
+
555
+ .md-toolbar-hint {
556
+ margin-left: auto;
557
+ font-size: 10px;
558
+ color: var(--color-text-muted, #888888);
559
+ opacity: 0.6;
560
+ white-space: nowrap;
561
+ }
562
+
563
+ .md-body {
564
+ display: flex;
565
+ flex: 1;
566
+ min-height: 0;
567
+ }
568
+
569
+ .md-body.split {
570
+ gap: 0;
571
+ }
572
+
573
+ .md-textarea {
574
+ flex: 1;
575
+ width: 100%;
576
+ box-sizing: border-box;
577
+ padding: var(--space-3, 12px);
578
+ background: transparent;
579
+ border: none;
580
+ color: var(--color-text, #e8e8e8);
581
+ font-family: inherit;
582
+ font-size: var(--text-sm, 14px);
583
+ line-height: var(--leading-normal, 1.6);
584
+ resize: vertical;
585
+ outline: none;
586
+ }
587
+
588
+ .md-textarea.monospace {
589
+ font-family: var(--font-mono, monospace);
590
+ }
591
+
592
+ .md-body.split .md-textarea {
593
+ flex: 1;
594
+ resize: none;
595
+ border-right: 1px solid var(--color-border, #2a2a2a);
596
+ }
597
+
598
+ .md-preview {
599
+ flex: 1;
600
+ padding: var(--space-3, 12px);
601
+ color: var(--color-text, #e8e8e8);
602
+ font-size: var(--text-sm, 14px);
603
+ line-height: var(--leading-normal, 1.6);
604
+ overflow-y: auto;
605
+ }
606
+
607
+ .md-preview.full {
608
+ min-height: 120px;
609
+ }
610
+
611
+ .md-preview-empty {
612
+ color: var(--color-text-muted, #888888);
613
+ font-style: italic;
614
+ }
615
+
616
+ /* Preview typography */
617
+ .md-preview :global(h1),
618
+ .md-preview :global(h2),
619
+ .md-preview :global(h3),
620
+ .md-preview :global(h4),
621
+ .md-preview :global(h5),
622
+ .md-preview :global(h6) {
623
+ margin: 0.6em 0 0.3em;
624
+ color: var(--color-text, #e8e8e8);
625
+ font-weight: 600;
626
+ line-height: 1.25;
627
+ }
628
+
629
+ .md-preview :global(h1) { font-size: 1.5em; }
630
+ .md-preview :global(h2) { font-size: 1.25em; }
631
+ .md-preview :global(h3) { font-size: 1.1em; }
632
+
633
+ .md-preview :global(p) {
634
+ margin: 0.4em 0;
635
+ }
636
+
637
+ .md-preview :global(strong) {
638
+ font-weight: 700;
639
+ color: var(--color-text, #e8e8e8);
640
+ }
641
+
642
+ .md-preview :global(em) {
643
+ font-style: italic;
644
+ }
645
+
646
+ .md-preview :global(code) {
647
+ font-family: var(--font-mono, monospace);
648
+ font-size: 0.875em;
649
+ padding: 0.15em 0.35em;
650
+ background: var(--surface-1, #161616);
651
+ border-radius: var(--radius-sm, 4px);
652
+ color: var(--color-accent, #6366f1);
653
+ }
654
+
655
+ .md-preview :global(pre) {
656
+ background: var(--surface-1, #161616);
657
+ border-radius: var(--radius-sm, 6px);
658
+ padding: var(--space-3, 12px);
659
+ overflow-x: auto;
660
+ margin: 0.5em 0;
661
+ }
662
+
663
+ .md-preview :global(pre code) {
664
+ background: transparent;
665
+ padding: 0;
666
+ font-size: 0.85em;
667
+ color: var(--color-text, #e8e8e8);
668
+ }
669
+
670
+ .md-preview :global(ul),
671
+ .md-preview :global(ol) {
672
+ margin: 0.4em 0;
673
+ padding-left: 1.5em;
674
+ }
675
+
676
+ .md-preview :global(li) {
677
+ margin: 0.2em 0;
678
+ }
679
+
680
+ .md-preview :global(hr) {
681
+ border: none;
682
+ border-top: 1px solid var(--color-border, #2a2a2a);
683
+ margin: 0.8em 0;
684
+ }
685
+
686
+ .md-preview :global(a) {
687
+ color: var(--color-accent, #6366f1);
688
+ text-decoration: underline;
689
+ }
690
+
691
+ /* ===== TUI mode ===== */
692
+ .tui-md {
693
+ display: inline-flex;
694
+ flex-direction: column;
695
+ font-family: var(--font-mono, monospace);
696
+ font-size: var(--text-sm, 14px);
697
+ line-height: 1.4;
698
+ color: var(--tui-text, #e0e0e0);
699
+ background: var(--tui-surface, #16213e);
700
+ }
701
+
702
+ .tui-md.disabled {
703
+ opacity: 0.4;
704
+ pointer-events: none;
705
+ }
706
+
707
+ .tui-top,
708
+ .tui-bottom {
709
+ color: var(--tui-border, #0f3460);
710
+ white-space: pre;
711
+ user-select: none;
712
+ }
713
+
714
+ .tui-top.focused,
715
+ .tui-bottom.focused {
716
+ color: var(--tui-accent, #00d4ff);
717
+ }
718
+
719
+ .tui-row {
720
+ display: flex;
721
+ white-space: pre;
722
+ }
723
+
724
+ .tui-side {
725
+ color: var(--tui-border, #0f3460);
726
+ user-select: none;
727
+ }
728
+
729
+ .tui-side.focused {
730
+ color: var(--tui-accent, #00d4ff);
731
+ }
732
+
733
+ .tui-textarea {
734
+ flex: 1;
735
+ background: transparent;
736
+ border: none;
737
+ outline: none;
738
+ color: var(--tui-text, #e0e0e0);
739
+ font-family: var(--font-mono, monospace);
740
+ font-size: inherit;
741
+ line-height: 1.4;
742
+ resize: none;
743
+ width: 36ch;
744
+ padding: 0;
745
+ }
746
+
747
+ .tui-preview {
748
+ flex: 1;
749
+ width: 36ch;
750
+ padding: 0;
751
+ color: var(--tui-text, #e0e0e0);
752
+ font-family: var(--font-mono, monospace);
753
+ font-size: inherit;
754
+ line-height: 1.4;
755
+ white-space: pre-wrap;
756
+ overflow: hidden;
757
+ }
758
+
759
+ .tui-toolbar {
760
+ display: flex;
761
+ gap: 4px;
762
+ padding: 2px 0;
763
+ font-family: var(--font-mono, monospace);
764
+ font-size: var(--text-sm, 14px);
765
+ }
766
+
767
+ .tui-tab {
768
+ color: var(--tui-text-dim, #888888);
769
+ cursor: pointer;
770
+ user-select: none;
771
+ padding: 0 4px;
772
+ }
773
+
774
+ .tui-tab.active {
775
+ color: var(--tui-accent, #00d4ff);
776
+ }
777
+
778
+ .tui-tab:focus-visible {
779
+ outline: 1px solid var(--tui-accent, #00d4ff);
780
+ }
781
+ </style>