clay-server 4.8.2-beta.1 → 4.9.0-beta.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 (56) hide show
  1. package/LICENSES/README.md +13 -0
  2. package/LICENSES/open-iconic.txt +21 -0
  3. package/LICENSES/plantuml-salt.txt +674 -0
  4. package/lib/assets/salt-assets.json +15 -0
  5. package/lib/assets/salt-colors.json +156 -0
  6. package/lib/assets/salt-font-10.bin.gz +0 -0
  7. package/lib/assets/salt-font-13.bin.gz +0 -0
  8. package/lib/assets/salt-font-14.bin.gz +0 -0
  9. package/lib/assets/salt-font-16.bin.gz +0 -0
  10. package/lib/assets/salt-font-18.bin.gz +0 -0
  11. package/lib/assets/salt-font-20.bin.gz +0 -0
  12. package/lib/assets/salt-font-metrics.bin.gz +0 -0
  13. package/lib/assets/salt-font-metrics.json +101 -0
  14. package/lib/assets/salt-font-monospace-10.bin.gz +0 -0
  15. package/lib/assets/salt-font-monospace-13.bin.gz +0 -0
  16. package/lib/assets/salt-font-monospace-14.bin.gz +0 -0
  17. package/lib/assets/salt-font-monospace-16.bin.gz +0 -0
  18. package/lib/assets/salt-font-monospace-18.bin.gz +0 -0
  19. package/lib/assets/salt-font-monospace-20.bin.gz +0 -0
  20. package/lib/assets/salt-font-monospace.bin.gz +0 -0
  21. package/lib/assets/salt-icons.json +671 -0
  22. package/lib/bundled-skills/clay-sketch/SKILL.md +198 -0
  23. package/lib/plantuml-renderer.js +43 -0
  24. package/lib/project-file-http.js +2 -0
  25. package/lib/project-plantuml.js +48 -0
  26. package/lib/project-session-document.js +17 -1
  27. package/lib/project-wireframe-http.js +88 -0
  28. package/lib/public/app.js +6 -0
  29. package/lib/public/css/filebrowser.css +5 -0
  30. package/lib/public/css/wireframe.css +95 -0
  31. package/lib/public/modules/app-messages.js +8 -1
  32. package/lib/public/modules/filebrowser.js +29 -3
  33. package/lib/public/modules/markdown.js +3 -1
  34. package/lib/public/modules/plantuml-preview.js +76 -0
  35. package/lib/public/modules/split-view.js +14 -0
  36. package/lib/public/modules/wireframe-chat.js +52 -0
  37. package/lib/public/modules/wireframe-render.js +69 -0
  38. package/lib/public/modules/wireframe-style.js +6 -0
  39. package/lib/public/modules/wireframe-viewport.js +70 -0
  40. package/lib/public/modules/wireframe-workbench.js +116 -0
  41. package/lib/public/style.css +1 -0
  42. package/lib/salt-chrome.js +36 -0
  43. package/lib/salt-hand.js +37 -0
  44. package/lib/salt-images.js +87 -0
  45. package/lib/salt-layout.js +60 -0
  46. package/lib/salt-options.js +23 -0
  47. package/lib/salt-overlays.js +45 -0
  48. package/lib/salt-parser.js +153 -0
  49. package/lib/salt-screen.js +111 -0
  50. package/lib/salt-svg-shapes.js +80 -0
  51. package/lib/salt-svg.js +164 -0
  52. package/lib/salt-text.js +112 -0
  53. package/lib/salt-tree.js +45 -0
  54. package/lib/sdk-bridge.js +2 -1
  55. package/lib/session-document-mcp-server.js +9 -0
  56. package/package.json +1 -1
@@ -12,6 +12,7 @@ import { enterMarkdownSlides, exitMarkdownSlides, handleMarkdownSlideKey, syncMa
12
12
  import { initFileViewerTabs, openFileViewerTab, previewFileViewerTab, updateFileViewerTab, closeFileViewerTab, focusedFileViewerTab, focusedFileViewerTabData, clearFileViewerTabs } from './filebrowser-tabs.js';
13
13
  import { initFileBrowserContextMenu, downloadProjectFile } from './filebrowser-context-menu.js';
14
14
  import { registerClayFileLinkOpener } from './clay-file-links.js';
15
+ import { isPlantUmlFile, renderPlantUmlPreview, cancelPlantUmlPreview } from './plantuml-preview.js';
15
16
 
16
17
  var ctx;
17
18
  var showDropHint = function () {};
@@ -195,8 +196,12 @@ export function initFileBrowser(_ctx) {
195
196
 
196
197
  // Markdown render toggle
197
198
  document.getElementById("file-viewer-render").addEventListener("click", function () {
198
- if (!currentContent || (!currentIsMarkdown && !currentIsSvg)) return;
199
+ if (currentContent == null || (!currentIsMarkdown && !currentIsSvg && !store.get("fileViewerIsPlantUml"))) return;
199
200
  isRendered = !isRendered;
201
+ if (store.get("fileViewerIsPlantUml")) {
202
+ renderPlantUmlBody();
203
+ return;
204
+ }
200
205
  if (currentIsSvg) {
201
206
  renderSvgBody();
202
207
  return;
@@ -485,6 +490,7 @@ export function closeFileViewer() {
485
490
  }
486
491
 
487
492
  function teardownFileViewer() {
493
+ cancelPlantUmlPreview();
488
494
  if (currentFilePath && isFollowingMarkdown(currentFilePath)) cancelMarkdownFollow();
489
495
  sendUnwatch();
490
496
  inlineDiffActive = false;
@@ -522,6 +528,8 @@ export function resetFileBrowser() {
522
528
  isRendered = false;
523
529
  currentIsMarkdown = false;
524
530
  currentIsSvg = false;
531
+ store.set({ fileViewerIsPlantUml: false });
532
+ cancelPlantUmlPreview();
525
533
  historyVisible = false;
526
534
  currentHistoryEntries = [];
527
535
  pendingNavigate = null;
@@ -1157,6 +1165,8 @@ function showFileContent(msg, expectedRevision) {
1157
1165
  currentFilePath = msg.path;
1158
1166
  currentIsMarkdown = false;
1159
1167
  currentIsSvg = false;
1168
+ store.set({ fileViewerIsPlantUml: false });
1169
+ cancelPlantUmlPreview();
1160
1170
  if (!keepRenderState) isRendered = false;
1161
1171
  var requestedExt = msg.path.split(".").pop().toLowerCase();
1162
1172
  if (pendingRenderedOpen && (requestedExt === "md" || requestedExt === "mdx")) {
@@ -1183,13 +1193,14 @@ function showFileContent(msg, expectedRevision) {
1183
1193
  lightweightPreview = (msg.size || 0) > FILE_RICH_PREVIEW_MAX_BYTES;
1184
1194
  currentIsMarkdown = !lightweightPreview && (ext === "md" || ext === "mdx");
1185
1195
  currentIsSvg = !lightweightPreview && ext === "svg";
1196
+ store.set({ fileViewerIsPlantUml: !lightweightPreview && isPlantUmlFile(msg.path) });
1186
1197
  if (lightweightPreview) isRendered = false;
1187
1198
  if (pendingRenderedOpen && currentIsMarkdown) isRendered = true;
1188
1199
 
1189
- if (currentIsMarkdown || currentIsSvg) {
1200
+ if (currentIsMarkdown || currentIsSvg || store.get("fileViewerIsPlantUml")) {
1190
1201
  renderBtn.classList.remove("hidden");
1191
1202
  renderBtn.title = currentIsSvg ? "Show SVG source" : "Render markdown";
1192
- copyBtn.title = currentIsSvg ? "Copy SVG source" : "Copy Markdown source";
1203
+ copyBtn.title = store.get("fileViewerIsPlantUml") ? "Copy PlantUML source" : currentIsSvg ? "Copy SVG source" : "Copy Markdown source";
1193
1204
  } else {
1194
1205
  copyBtn.title = "Copy contents";
1195
1206
  }
@@ -1203,6 +1214,9 @@ function showFileContent(msg, expectedRevision) {
1203
1214
  ? (previousContent == null ? "" : previousContent)
1204
1215
  : null;
1205
1216
  renderBody(transitionFrom, refreshSlideIndex);
1217
+ } else if (store.get("fileViewerIsPlantUml")) {
1218
+ if (!keepRenderState) isRendered = !store.get("pendingFileNavigation");
1219
+ renderPlantUmlBody();
1206
1220
  } else if (currentIsSvg) {
1207
1221
  if (!keepRenderState) isRendered = true;
1208
1222
  renderSvgBody();
@@ -1269,6 +1283,16 @@ function showFileContent(msg, expectedRevision) {
1269
1283
  }
1270
1284
  }
1271
1285
 
1286
+ function renderPlantUmlBody() {
1287
+ var bodyEl = document.getElementById("file-viewer-body");
1288
+ var renderBtn = document.getElementById("file-viewer-render");
1289
+ cancelPlantUmlPreview();
1290
+ renderBtn.classList.toggle("active", isRendered);
1291
+ renderBtn.title = isRendered ? "Show source" : "Show diagram preview";
1292
+ if (isRendered) renderPlantUmlPreview(bodyEl, currentContent, currentFilePath);
1293
+ else renderCodeWithLineNumbers(bodyEl, currentContent, "plaintext");
1294
+ }
1295
+
1272
1296
  function renderSvgBody() {
1273
1297
  var bodyEl = document.getElementById("file-viewer-body");
1274
1298
  var renderBtn = document.getElementById("file-viewer-render");
@@ -1626,6 +1650,8 @@ function rerenderFileContent() {
1626
1650
 
1627
1651
  if (currentIsMarkdown) {
1628
1652
  renderBody();
1653
+ } else if (store.get("fileViewerIsPlantUml")) {
1654
+ renderPlantUmlBody();
1629
1655
  } else if (currentIsSvg) {
1630
1656
  renderSvgBody();
1631
1657
  } else {
@@ -1,3 +1,4 @@
1
+ import { renderWireframeBlocks } from './wireframe-chat.js';
1
2
  import { copyToClipboard, escapeHtml } from './utils.js';
2
3
  import { refreshIcons } from './icons.js';
3
4
  import { getMermaidThemeVars } from './theme.js';
@@ -128,7 +129,7 @@ export function highlightCodeBlocks(el) {
128
129
  // messages container, so unhighlighted blocks (selector :not(.hljs))
129
130
  // are picked up in a single pass.
130
131
  if (store.get('replayingHistory')) return;
131
- el.querySelectorAll("pre code:not(.hljs):not(.language-mermaid)").forEach(function (block) {
132
+ el.querySelectorAll("pre code:not(.hljs):not(.language-mermaid):not(.language-plantuml):not(.language-puml):not(.language-salt):not(.language-clay-sketch)").forEach(function (block) {
132
133
  var cls = Array.from(block.classList).find(function (c) { return c.startsWith("language-"); });
133
134
  if (cls) {
134
135
  var lang = cls.replace("language-", "");
@@ -167,6 +168,7 @@ export function renderMermaidBlocks(el) {
167
168
  // Same rationale as highlightCodeBlocks: defer mermaid renders until
168
169
  // history_done so they don't cause cascading layout shifts during replay.
169
170
  if (store.get('replayingHistory')) return;
171
+ renderWireframeBlocks(el);
170
172
  var blocks = el.querySelectorAll("pre code.language-mermaid");
171
173
  blocks.forEach(function (codeEl) {
172
174
  var pre = codeEl.parentElement;
@@ -0,0 +1,76 @@
1
+ import { store } from './store.js';
2
+ import { mountWireframeViewport } from './wireframe-viewport.js';
3
+ import { sketchBrand, sketchCredit } from './wireframe-style.js';
4
+
5
+ export function isPlantUmlFile(path) {
6
+ return /\.(puml|plantuml|pu|uml|salt)$/i.test(String(path || ""));
7
+ }
8
+
9
+ export function cancelPlantUmlPreview() {
10
+ var job = store.get('plantUmlPreviewJob');
11
+ if (!job) return;
12
+ clearTimeout(job.timer);
13
+ job.observer.disconnect();
14
+ job.controller.abort();
15
+ store.set({ plantUmlPreviewJob: null });
16
+ }
17
+
18
+ export function renderPlantUmlPreview(body, source, path) {
19
+ cancelPlantUmlPreview();
20
+ body.innerHTML = "";
21
+ var preview = document.createElement("div");
22
+ preview.className = "file-viewer-plantuml-preview";
23
+ var status = document.createElement("div");
24
+ status.className = "file-tree-loading";
25
+ status.setAttribute("role", "status");
26
+ status.textContent = "Rendering diagram…";
27
+ preview.appendChild(status);
28
+ body.appendChild(preview);
29
+
30
+ function showError(message) {
31
+ status.className = "file-tree-error";
32
+ status.textContent = message + " Use Show source to inspect the file.";
33
+ }
34
+ if (!String(source || "").trim()) { showError("This diagram is empty."); return; }
35
+ if (new TextEncoder().encode(source).length > 100000) { showError("Diagram is too large to preview (100 KB maximum)."); return; }
36
+
37
+ var job = { controller: new AbortController(), observer: null, timer: null };
38
+ job.observer = new MutationObserver(function () {
39
+ if (!preview.isConnected && store.get('plantUmlPreviewJob') === job) cancelPlantUmlPreview();
40
+ });
41
+ job.observer.observe(body, { childList: true });
42
+ job.timer = setTimeout(function () {
43
+ if (store.get('plantUmlPreviewJob') !== job) return;
44
+ showError("The diagram preview timed out.");
45
+ cancelPlantUmlPreview();
46
+ }, 20000);
47
+ store.set({ plantUmlPreviewJob: job });
48
+ fetch("api/file/plantuml?path=" + encodeURIComponent(path), { signal: job.controller.signal, cache: "no-store" }).then(function (response) {
49
+ return response.text().then(function (text) {
50
+ if (!response.ok) throw new Error(text || "Unable to render this diagram.");
51
+ return text;
52
+ });
53
+ }).then(function (svg) {
54
+ if (store.get('plantUmlPreviewJob') !== job || !preview.isConnected) return;
55
+ if (svg.length > 8 * 1024 * 1024 || !/<svg[\s>]/i.test(svg)) throw new Error("The renderer returned an invalid diagram.");
56
+ var image = document.createElement("img");
57
+ image.alt = "Diagram preview of " + path;
58
+ image.draggable = false;
59
+ // An image context disables scripts, links and external SVG resources.
60
+ image.src = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
61
+ image.addEventListener("error", function () { showError("The diagram image could not be displayed."); });
62
+ status.textContent = "";
63
+ var heading = document.createElement('header'); heading.className = 'sketch-tools';
64
+ heading.innerHTML = '<div class="sketch-brand">' + sketchBrand() + '</div>' + sketchCredit();
65
+ preview.appendChild(heading);
66
+ var toolbar = document.createElement('div');
67
+ var viewport = document.createElement('div'); viewport.className = 'wireframe-file-canvas';
68
+ preview.appendChild(toolbar); preview.appendChild(viewport); viewport.appendChild(image);
69
+ mountWireframeViewport(viewport, image, toolbar);
70
+
71
+ }).catch(function (error) {
72
+ if (store.get('plantUmlPreviewJob') === job && error.name !== "AbortError") showError(error.message);
73
+ }).finally(function () {
74
+ if (store.get('plantUmlPreviewJob') === job) cancelPlantUmlPreview();
75
+ });
76
+ }
@@ -1,3 +1,4 @@
1
+ import { openWireframe } from './wireframe-workbench.js';
1
2
  // Two-pane iframe shell for the split-view spike.
2
3
 
3
4
  import { store } from './store.js';
@@ -142,6 +143,19 @@ function handlePaneMessage(event) {
142
143
  if (event.origin !== window.location.origin) return;
143
144
  var msg = event.data;
144
145
  if (!msg || !host) return;
146
+ if (msg.type === "clay-pane-present-wireframe") {
147
+ var frames = host.querySelectorAll(".split-pane-frame");
148
+ var split = store.get('splitPanes');
149
+ var trusted = Array.prototype.some.call(frames, function (frame) {
150
+ if (frame.contentWindow !== event.source || frame.dataset.projectSlug !== msg.projectSlug || String(frame.dataset.sessionId) !== String(msg.sessionId) || msg.projectSlug !== store.get('currentSlug')) return false;
151
+ return split && split.panes.some(function (pane) {
152
+ var expected = new URL('/p/' + encodeURIComponent(pane.slug) + '/?pane=1&session=' + encodeURIComponent(pane.sessionId), window.location.href).href;
153
+ return pane.slug === msg.projectSlug && String(pane.sessionId) === String(msg.sessionId) && frame.src === expected;
154
+ });
155
+ });
156
+ if (trusted && msg.message) openWireframe(msg.message);
157
+ return;
158
+ }
145
159
  if (msg.type === "clay-pane-present-markdown") {
146
160
  var present = msg.message;
147
161
  if (present) presentMarkdownEdit(present);
@@ -0,0 +1,52 @@
1
+ import { sketchBrand, sketchCredit } from './wireframe-style.js';
2
+ import { store } from './store.js';
3
+ import { renderWireframe, wireframeSource, wireframeImage } from './wireframe-render.js';
4
+ import { openWireframe } from './wireframe-workbench.js';
5
+ import { mountWireframeViewport } from './wireframe-viewport.js';
6
+
7
+ export function renderWireframeBlocks(root) {
8
+ if (store.get('replayingHistory')) return;
9
+ root.querySelectorAll('pre code.language-clay-sketch, pre code.language-plantuml, pre code.language-puml, pre code.language-salt').forEach(function (code) {
10
+ var pre = code.parentElement;
11
+ if (!pre || pre.dataset.wireframeProcessed) return;
12
+ var source = wireframeSource(code.textContent, code.classList.contains('language-salt') ? 'salt' : 'plantuml');
13
+ // Wait for a complete PlantUML body while assistant text is streaming.
14
+ if (!/^\s*@end(?:salt|uml)\b/im.test(source)) return;
15
+ pre.dataset.wireframeProcessed = 'true';
16
+ var card = document.createElement('section');
17
+ card.className = 'wireframe-card';
18
+ card.setAttribute('aria-label', 'clay-sketch preview');
19
+ var header = document.createElement('header');
20
+ var label = document.createElement('div'); label.className = 'sketch-brand'; label.innerHTML = sketchBrand(); header.appendChild(label);
21
+ var open = document.createElement('button'); open.type = 'button'; open.dataset.openSketch = ''; open.textContent = 'Open sketch';
22
+ open.addEventListener('click', function (event) { event.stopPropagation(); openWireframe({ source: source, title: 'Sketch viewer' }); });
23
+ header.appendChild(open); card.appendChild(header);
24
+ var canvas = document.createElement('div'); canvas.className = 'wireframe-canvas'; card.appendChild(canvas);
25
+ var status = document.createElement('p'); status.setAttribute('role', 'status'); status.textContent = 'Rendering sketch…'; canvas.appendChild(status);
26
+ var details = document.createElement('details');
27
+ var summary = document.createElement('summary'); summary.textContent = 'Sketch source'; details.appendChild(summary);
28
+ pre.replaceWith(card); details.appendChild(pre);
29
+ var footer = document.createElement('div'); footer.className = 'sketch-source-footer'; footer.appendChild(details);
30
+ var credit = document.createElement('div'); credit.innerHTML = sketchCredit(); footer.appendChild(credit); card.appendChild(footer);
31
+ function render(current) {
32
+ canvas.replaceChildren(status); status.textContent = 'Rendering sketch…';
33
+ return renderWireframe(current, canvas).then(function (svg) {
34
+ if (!card.isConnected) return;
35
+ var image = wireframeImage(svg, 'clay-sketch preview');
36
+ canvas.replaceChildren(image);
37
+ mountWireframeViewport(canvas, image);
38
+ }).catch(function (error) {
39
+ if (!card.isConnected) return;
40
+ status.textContent = error.name === 'AbortError' ? 'Rendering was interrupted. Try again.' : error.message;
41
+ var retry = document.createElement('button'); retry.type = 'button'; retry.textContent = 'Retry';
42
+ retry.onclick = function () {
43
+ retry.remove();
44
+ render(current).catch(function () {});
45
+ };
46
+ canvas.appendChild(retry);
47
+ throw error;
48
+ });
49
+ }
50
+ render(source).catch(function () {});
51
+ });
52
+ }
@@ -0,0 +1,69 @@
1
+ import { store } from './store.js';
2
+
3
+ export function wireframeSource(source, language) {
4
+ source = String(source || '');
5
+ if (language === 'salt' && !/@start\w+/i.test(source)) return '@startsalt\n' + source + '\n@endsalt';
6
+ return source;
7
+ }
8
+
9
+ export function wireframeImage(svg, title) {
10
+ if (svg.length > 8 * 1024 * 1024 || !/<svg[\s>]/i.test(svg)) throw new Error('The renderer returned an invalid diagram.');
11
+ var image = document.createElement('img');
12
+ image.alt = title || 'Wireframe';
13
+ image.draggable = false;
14
+ // SVG is displayed in an inert image context, never inserted as markup.
15
+ image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
16
+ return image;
17
+ }
18
+
19
+ function pump() {
20
+ if (store.get('wireframeRenderActive')) return;
21
+ var queue = (store.get('wireframeRenderQueue') || []).slice();
22
+ var job = queue.shift();
23
+ if (!job) return;
24
+ store.set({ wireframeRenderQueue: queue });
25
+ if (!job.target.isConnected) { job.reject(new Error('Preview closed.')); pump(); return; }
26
+ var controller = new AbortController();
27
+ store.set({ wireframeRenderActive: controller });
28
+ var timer = setTimeout(function () { controller.abort(); }, 20000);
29
+ fetch('api/wireframe/render', { method: 'POST', headers: { 'Content-Type': 'application/json' },
30
+ body: JSON.stringify({ source: job.source }), signal: controller.signal, cache: 'no-store' }).then(function (response) {
31
+ return response.text().then(function (text) {
32
+ if (!response.ok) throw new Error(text || 'Unable to render this wireframe.');
33
+ return text;
34
+ });
35
+ }).then(function (svg) {
36
+ if (store.get('wireframeRenderActive') !== controller) throw new Error('Conversation changed.');
37
+ wireframeImage(svg);
38
+ if (svg.length < 500000) {
39
+ var cache = (store.get('wireframeRenderCache') || []).filter(function (entry) { return entry.source !== job.source; });
40
+ cache.push({ source: job.source, svg: svg });
41
+ store.set({ wireframeRenderCache: cache.slice(-12) });
42
+ }
43
+ job.resolve(svg);
44
+ }).catch(function (error) { job.reject(error); }).finally(function () {
45
+ clearTimeout(timer);
46
+ if (store.get('wireframeRenderActive') === controller) store.set({ wireframeRenderActive: null });
47
+ pump();
48
+ });
49
+ }
50
+
51
+ export function renderWireframe(source, target) {
52
+ if (!source.trim() || new TextEncoder().encode(source).length > 100000) return Promise.reject(new Error('Wireframes must contain between 1 byte and 100 KB of source.'));
53
+ var cache = store.get('wireframeRenderCache') || [];
54
+ var hit = cache.find(function (entry) { return entry.source === source; });
55
+ if (hit) return Promise.resolve(hit.svg);
56
+ return new Promise(function (resolve, reject) {
57
+ var queue = (store.get('wireframeRenderQueue') || []).slice();
58
+ queue.push({ source: source, target: target, resolve: resolve, reject: reject });
59
+ store.set({ wireframeRenderQueue: queue });
60
+ pump();
61
+ });
62
+ }
63
+
64
+ store.subscribe(function (state, previous) {
65
+ if (state.currentSlug === previous.currentSlug && state.activeSessionId === previous.activeSessionId && state.myUserId === previous.myUserId) return;
66
+ if (previous.wireframeRenderActive) previous.wireframeRenderActive.abort();
67
+ (previous.wireframeRenderQueue || []).forEach(function (job) { job.reject(new Error('Conversation changed.')); });
68
+ store.set({ wireframeRenderActive: null, wireframeRenderQueue: [], wireframeRenderCache: [] });
69
+ });
@@ -0,0 +1,6 @@
1
+ export function sketchBrand() {
2
+ return '<span class="sketch-mark" aria-hidden="true">✎</span><span class="sketch-wordmark">clay<span>sketch</span></span>';
3
+ }
4
+ export function sketchCredit() {
5
+ return '<a class="sketch-credit" href="https://plantuml.com/salt" target="_blank" rel="noopener noreferrer">Built with PlantUML Salt <span aria-hidden="true">↗</span></a>';
6
+ }
@@ -0,0 +1,70 @@
1
+ import { store } from './store.js';
2
+
3
+ function entries() { return store.get('wireframeViewports') || new Map(); }
4
+ function apply(entry) {
5
+ var image = entry.image;
6
+ if (!image.naturalWidth || !entry.viewport.isConnected || entries().get(entry.viewport) !== entry) return;
7
+ var style = getComputedStyle(entry.viewport);
8
+ var available = entry.viewport.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
9
+ if (available <= 0) return;
10
+ var fitted = Math.min(1, available / image.naturalWidth);
11
+ if (entry.viewport.classList.contains('wireframe-workbench-canvas')) {
12
+ var height = entry.viewport.clientHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
13
+ if (height > 0) fitted = Math.min(fitted, height / image.naturalHeight);
14
+ }
15
+ // Default to the whole design at natural scale or smaller; zoom is explicit.
16
+ var scale = entry.mode === 'manual' ? entry.zoom : fitted;
17
+ image.style.width = image.naturalWidth * scale + 'px';
18
+ image.style.maxWidth = 'none';
19
+ image.style.height = 'auto';
20
+ image.style.margin = image.naturalWidth * scale > available ? '0' : '0 auto';
21
+ entry.scale = scale;
22
+ if (!entry.toolbar) return;
23
+ entry.toolbar.querySelector('[data-zoom-value]').textContent = Math.round(scale * 100) + '%';
24
+ entry.toolbar.querySelector('[data-zoom-fit]').setAttribute('aria-pressed', String(entry.mode === 'fit'));
25
+ entry.toolbar.querySelector('[data-zoom-out]').disabled = scale <= 0.25;
26
+ entry.toolbar.querySelector('[data-zoom-in]').disabled = scale >= 4;
27
+ }
28
+ function cleanup() {
29
+ var next = new Map(entries());
30
+ next.forEach(function (entry, viewport) {
31
+ if (viewport.isConnected && entry.image.isConnected) return;
32
+ entry.resize.disconnect(); next.delete(viewport);
33
+ });
34
+ if (next.size !== entries().size) store.set({ wireframeViewports: next });
35
+ if (!next.size) {
36
+ var observer = store.get('wireframeViewportCleanup');
37
+ if (observer) observer.disconnect();
38
+ store.set({ wireframeViewportCleanup: null });
39
+ }
40
+ }
41
+ export function mountWireframeViewport(viewport, image, toolbar) {
42
+ var previous = entries().get(viewport);
43
+ if (previous) previous.resize.disconnect();
44
+ var entry = { viewport: viewport, image: image, toolbar: toolbar, mode: previous ? previous.mode : 'auto', zoom: previous ? previous.zoom : 1, scale: 1 };
45
+ entry.resize = new ResizeObserver(function () { apply(entry); });
46
+ var next = new Map(entries()); next.set(viewport, entry);
47
+ store.set({ wireframeViewports: next });
48
+ if (toolbar) {
49
+ toolbar.classList.add('wireframe-zoom');
50
+ toolbar.setAttribute('role', 'group'); toolbar.setAttribute('aria-label', 'Sketch zoom');
51
+ toolbar.innerHTML = '<button type="button" data-zoom-out aria-label="Zoom out" title="Zoom out">−</button><button type="button" data-zoom-value aria-label="Reset zoom to 100%" title="Original size">100%</button><button type="button" data-zoom-in aria-label="Zoom in" title="Zoom in">+</button><button type="button" data-zoom-fit aria-pressed="true" title="Fit sketch to available width">Fit</button>';
52
+ function change(mode, zoom) {
53
+ entry.mode = mode; entry.zoom = zoom;
54
+ viewport.scrollLeft = 0; viewport.scrollTop = 0;
55
+ apply(entry);
56
+ }
57
+ toolbar.querySelector('[data-zoom-out]').onclick = function () { change('manual', Math.max(0.25, entry.scale / 1.25)); };
58
+ toolbar.querySelector('[data-zoom-in]').onclick = function () { change('manual', Math.min(4, Math.max(0.25, entry.scale * 1.25))); };
59
+ toolbar.querySelector('[data-zoom-value]').onclick = function () { change('manual', 1); };
60
+ toolbar.querySelector('[data-zoom-fit]').onclick = function () { change('fit', 1); };
61
+ }
62
+ image.addEventListener('load', function () { apply(entry); }, { once: true });
63
+ entry.resize.observe(viewport);
64
+ if (!store.get('wireframeViewportCleanup')) {
65
+ var observer = new MutationObserver(cleanup);
66
+ observer.observe(document.body, { childList: true, subtree: true });
67
+ store.set({ wireframeViewportCleanup: observer });
68
+ }
69
+ apply(entry);
70
+ }
@@ -0,0 +1,116 @@
1
+ import { sketchBrand, sketchCredit } from './wireframe-style.js';
2
+ import { store } from './store.js';
3
+ import { claimRightWorkbench, registerRightWorkbench, releaseRightWorkbench } from './right-workbench.js';
4
+ import { renderWireframe, wireframeSource, wireframeImage } from './wireframe-render.js';
5
+ import { refreshIcons } from './icons.js';
6
+ import { mountWireframeViewport } from './wireframe-viewport.js';
7
+
8
+ function panel() { return document.getElementById('wireframe-panel'); }
9
+ export function closeWireframe() {
10
+ releaseRightWorkbench('wireframe');
11
+ if (panel()) panel().remove();
12
+ store.set({ wireframeDraft: null });
13
+ }
14
+
15
+ function download() {
16
+ var draft = store.get('wireframeDraft');
17
+ if (!draft) return;
18
+ var link = document.createElement('a');
19
+ link.href = URL.createObjectURL(new Blob([draft.source], { type: 'text/plain;charset=utf-8' }));
20
+ link.download = (draft.id || 'wireframe') + '.puml';
21
+ link.click();
22
+ setTimeout(function () { URL.revokeObjectURL(link.href); }, 1000);
23
+ }
24
+
25
+ function save(event) {
26
+ event.preventDefault();
27
+ var el = panel();
28
+ var draft = store.get('wireframeDraft');
29
+ var form = el.querySelector('form');
30
+ var button = form.querySelector('[type="submit"]');
31
+ var status = el.querySelector('.wireframe-save-status');
32
+ var requested = form.elements.path.value.trim();
33
+ button.disabled = true; status.textContent = 'Saving…';
34
+ fetch('api/wireframe/save', { method: 'POST', headers: { 'Content-Type': 'application/json' },
35
+ body: JSON.stringify({ path: requested, source: draft.source }) }).then(function (response) {
36
+ if (!response.ok) return response.text().then(function (text) { throw new Error(text); });
37
+ return response.json();
38
+ }).then(function (result) {
39
+ if (store.get('wireframeDraft') !== draft) return;
40
+ status.textContent = 'Saved to ' + result.path;
41
+ form.hidden = true;
42
+ }).catch(function (error) {
43
+ if (store.get('wireframeDraft') === draft) status.textContent = error.message || 'Unable to save the wireframe.';
44
+ }).finally(function () { button.disabled = false; });
45
+ }
46
+
47
+ function createPanel() {
48
+ var host = document.getElementById('main-panels');
49
+ if (!host) return null;
50
+ var el = document.createElement('section'); el.id = 'wireframe-panel';
51
+ el.setAttribute('aria-label', 'clay-sketch viewer');
52
+ el.innerHTML = '<header class="wireframe-topbar"><div class="sketch-brand">' + sketchBrand() + '</div><strong class="sketch-title">Sketch viewer</strong><div class="wireframe-window-actions"><button type="button" data-wide title="Widen panel" aria-label="Widen panel" aria-pressed="false"><i data-lucide="chevrons-left-right"></i></button><button type="button" data-full title="Toggle fullscreen" aria-label="Toggle fullscreen" aria-pressed="false"><i data-lucide="maximize-2"></i></button><button type="button" data-close title="Close clay-sketch" aria-label="Close clay-sketch"><i data-lucide="x"></i></button></div></header><div class="wireframe-toolbar"><div class="wireframe-zoom"></div><button type="button" data-save>Save to project</button><button type="button" data-download title="Download sketch source" aria-label="Download sketch source"><i data-lucide="download"></i></button></div><form hidden class="wireframe-save-form"><label>Project file path<input name="path" aria-label="Project file path" required pattern=".*\\.puml" placeholder="wireframe.puml"></label><button type="submit">Save</button><button type="button" data-cancel>Cancel</button></form><p class="wireframe-save-status" role="status"></p><div class="wireframe-workbench-canvas"></div><footer class="sketch-footer">' + sketchCredit() + '<span>Revise through conversation</span></footer>';
53
+ host.appendChild(el);
54
+ el.querySelector('[data-close]').onclick = closeWireframe;
55
+ el.querySelector('[data-download]').onclick = download;
56
+ el.querySelector('[data-save]').onclick = function () {
57
+ var form = el.querySelector('form'); form.hidden = false;
58
+ var draft = store.get('wireframeDraft');
59
+ form.elements.path.value = (draft.id || 'wireframe') + '.puml';
60
+ form.elements.path.focus();
61
+ };
62
+ el.querySelector('[data-cancel]').onclick = function () { el.querySelector('form').hidden = true; el.querySelector('[data-save]').focus(); };
63
+ el.querySelector('form').onsubmit = save;
64
+ el.addEventListener('keydown', function (event) {
65
+ if (event.key !== 'Escape') return;
66
+ event.stopPropagation();
67
+ if (!el.querySelector('form').hidden) el.querySelector('[data-cancel]').click();
68
+ else closeWireframe();
69
+ });
70
+ ['wide', 'full'].forEach(function (action) {
71
+ el.querySelector('[data-' + action + ']').onclick = function () {
72
+ var active = el.classList.toggle(action === 'wide' ? 'wireframe-wide' : 'panel-fullscreen');
73
+ this.setAttribute('aria-pressed', String(active));
74
+ };
75
+ });
76
+ refreshIcons();
77
+ return el;
78
+ }
79
+
80
+ export function openWireframe(message) {
81
+ if (!message || typeof message.source !== 'string' || new TextEncoder().encode(message.source).length > 100000) return;
82
+ if (store.get('paneMode') && window.parent !== window) {
83
+ window.parent.postMessage({ type: 'clay-pane-present-wireframe', message: message, projectSlug: store.get('currentSlug'), sessionId: store.get('activeSessionId') }, window.location.origin);
84
+ return;
85
+ }
86
+ claimRightWorkbench('wireframe');
87
+ var el = panel() || createPanel();
88
+ if (!el) return;
89
+ var draft = { source: wireframeSource(message.source, message.language), title: String(message.title || 'clay-sketch').slice(0, 100),
90
+ id: /^[a-zA-Z0-9_-]{1,80}$/.test(message.id || '') ? message.id : 'wireframe' };
91
+ store.set({ wireframeDraft: draft });
92
+ el.querySelector('header strong').textContent = draft.title;
93
+ el.querySelector('.wireframe-save-status').textContent = '';
94
+ el.querySelector('form').hidden = true;
95
+ draw(draft, el).catch(function () {});
96
+ }
97
+ function draw(draft, el) {
98
+ var canvas = el.querySelector('.wireframe-workbench-canvas');
99
+ var active = store.get('wireframeDraft');
100
+ var status = document.createElement('p'); status.setAttribute('role', 'status'); status.textContent = 'Rendering sketch…';
101
+ canvas.replaceChildren(status);
102
+ return renderWireframe(draft.source, canvas).then(function (svg) {
103
+ if (store.get('wireframeDraft') !== active || !canvas.isConnected) throw new DOMException('Sketch changed.', 'AbortError');
104
+ var image = wireframeImage(svg, draft.title);
105
+ canvas.replaceChildren(image);
106
+ mountWireframeViewport(canvas, image, el.querySelector('.wireframe-zoom'));
107
+ }).catch(function (error) {
108
+ if (store.get('wireframeDraft') !== active) throw error;
109
+ status.textContent = error.name === 'AbortError' ? 'Rendering was interrupted. Reopen the wireframe to retry.' : error.message;
110
+ throw error;
111
+ });
112
+ }
113
+ registerRightWorkbench('wireframe', closeWireframe);
114
+ store.subscribe(function (state, previous) {
115
+ if (state.currentSlug !== previous.currentSlug || state.activeSessionId !== previous.activeSessionId || state.myUserId !== previous.myUserId) closeWireframe();
116
+ });
@@ -1,3 +1,4 @@
1
+ @import url("css/wireframe.css");
1
2
  @import url("css/session-github.css");
2
3
  @import url("css/vendor-login-browser.css");
3
4
  @import url("css/issues.css");
@@ -0,0 +1,36 @@
1
+ // Diagram decoration geometry adapted from PlantUML 1.2026.8 (GPL-3.0-or-later).
2
+ var text = require('./salt-text');
3
+ function decorate(root, draw, shapes) {
4
+ var options = root.options || {};
5
+ var block = { width: root.width, height: root.height, draw: draw };
6
+ function add(name, top) {
7
+ if (!options[name]) return;
8
+ var small = name === 'header' || name === 'footer';
9
+ var padding = name === 'title' || name === 'legend' ? 5 : 0;
10
+ var margin = name === 'legend' ? 12 : name === 'title' ? 5 : name === 'caption' ? 1 : 0;
11
+ var labels = options.labels[name].map(function (parsed) {
12
+ parsed.runs.forEach(function (run) { run.size = small ? 10 : 14; run.bold = name === 'title' || run.bold; run.color = small ? '#888' : run.color; });
13
+ return text.measureLabel(parsed);
14
+ });
15
+ var textWidth = Math.max.apply(null, labels.map(function (label) { return label.width; }));
16
+ var textHeight = labels.reduce(function (sum, label) { return sum + label.height; }, 0);
17
+ var width = textWidth + 2 * (padding + margin) + 1;
18
+ var height = textHeight + 2 * (padding + margin) + 1;
19
+ var previous = block;
20
+ block = { width: Math.max(previous.width, width), height: previous.height + height, draw: function (x, y) {
21
+ var bx = x + (name === 'header' ? blockWidth - width : (blockWidth - width) / 2);
22
+ var by = y + (top ? 0 : previous.height);
23
+ previous.draw(x + (blockWidth - previous.width) / 2, y + (top ? height : 0));
24
+ if (name === 'legend') shapes.rect(bx + margin, by + margin, textWidth + 2 * padding, textHeight + 2 * padding, '#DDD', '#000', 7.5);
25
+ var yy = by + padding + margin;
26
+ labels.forEach(function (label) {
27
+ var offset = name === 'legend' ? 0 : name === 'header' ? textWidth - label.width : (textWidth - label.width) / 2;
28
+ shapes.label(label, bx + margin + padding + offset, yy); yy += label.height;
29
+ });
30
+ } };
31
+ var blockWidth = block.width;
32
+ }
33
+ add('legend', false); add('title', true); add('caption', false); add('header', true); add('footer', false);
34
+ return block;
35
+ }
36
+ module.exports = { decorate: decorate };
@@ -0,0 +1,37 @@
1
+ // Adapted from PlantUML HandJiggle/URectangleHand by Adrian Vogt, GPL-3.0-or-later.
2
+ // See LICENSES/plantuml-salt.txt. Each translated reference graphic resets its seed.
3
+ function jiggle(x, y, variation) {
4
+ var seed = (424242n ^ 25214903917n) & ((1n << 48n) - 1n);
5
+ function bits(n) { seed = (seed * 25214903917n + 11n) & ((1n << 48n) - 1n); return Number(seed >> BigInt(48 - n)); }
6
+ function random() { return (bits(26) * 134217728 + bits(27)) / 9007199254740992; }
7
+ var points = [[x, y]];
8
+ function lineTo(xx, yy) {
9
+ var dx = Math.abs(xx - x); var dy = Math.abs(yy - y); var distance = Math.hypot(dx, dy);
10
+ if (distance < 0.001) return;
11
+ var segments = Math.round(distance / 10); var amount = variation;
12
+ if (segments < 5) { segments = 5; amount /= 3; }
13
+ for (var i = 0; i < segments; i++) {
14
+ var offset = (random() - 0.5) * amount;
15
+ points.push([x + (xx - x) / segments * i - offset * dy / distance, y + (yy - y) / segments * i - offset * dx / distance]);
16
+ }
17
+ points.push([xx, yy]); x = xx; y = yy;
18
+ }
19
+ function arc(a, b, cx, cy, rx, ry) {
20
+ [(a + b) / 2, b].forEach(function (angle) { lineTo(cx + Math.cos(angle) * rx, cy + Math.sin(angle) * ry); });
21
+ }
22
+ return { points: points, lineTo: lineTo, arc: arc };
23
+ }
24
+ function line(dx, dy) { var j = jiggle(0, 0, 2); j.lineTo(dx, dy); return j.points; }
25
+ function rect(w, h, radius) {
26
+ var rx = Math.min(radius || 0, w / 2); var ry = Math.min(radius || 0, h / 2);
27
+ var j = jiggle(rx, 0, 1.5);
28
+ if (!radius) { j.lineTo(w, 0); j.lineTo(w, h); j.lineTo(0, h); j.lineTo(0, 0); }
29
+ else {
30
+ j.lineTo(w - rx, 0); j.arc(-Math.PI / 2, 0, w - rx, ry, rx, ry);
31
+ j.lineTo(w, h - ry); j.arc(0, Math.PI / 2, w - rx, h - ry, rx, ry);
32
+ j.lineTo(rx, h); j.arc(Math.PI / 2, Math.PI, rx, h - ry, rx, ry);
33
+ j.lineTo(0, ry); j.arc(Math.PI, Math.PI * 1.5, rx, ry, rx, ry);
34
+ }
35
+ return j.points;
36
+ }
37
+ module.exports = { line: line, rect: rect };