pi-tau-web-server 1.0.8

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 (98) hide show
  1. package/README.md +303 -0
  2. package/bin/auth.js +96 -0
  3. package/bin/config.js +99 -0
  4. package/bin/model-utils.js +134 -0
  5. package/bin/server-main.js +1189 -0
  6. package/bin/sessions.js +492 -0
  7. package/bin/tau.js +8 -0
  8. package/bin/tree.js +248 -0
  9. package/bin/types.js +2 -0
  10. package/package.json +74 -0
  11. package/public/app-main.js +2025 -0
  12. package/public/app-types.js +1 -0
  13. package/public/app.js +1 -0
  14. package/public/command-palette.js +42 -0
  15. package/public/dialogs.js +199 -0
  16. package/public/file-browser.js +196 -0
  17. package/public/icons/apple-touch-icon.png +0 -0
  18. package/public/icons/favicon-16.png +0 -0
  19. package/public/icons/favicon-32.png +0 -0
  20. package/public/icons/tau-192.png +0 -0
  21. package/public/icons/tau-512.png +0 -0
  22. package/public/icons/tau-logo.png +0 -0
  23. package/public/icons/tau-logo.svg +13 -0
  24. package/public/icons/tau-maskable-512.png +0 -0
  25. package/public/icons/tau-new.png +0 -0
  26. package/public/index.html +264 -0
  27. package/public/launcher-panel.js +54 -0
  28. package/public/launcher.js +84 -0
  29. package/public/manifest.json +28 -0
  30. package/public/markdown.js +336 -0
  31. package/public/message-renderer.js +268 -0
  32. package/public/model-picker.js +478 -0
  33. package/public/session-sidebar.js +460 -0
  34. package/public/session-stats-card.js +123 -0
  35. package/public/state.js +81 -0
  36. package/public/style.css +3864 -0
  37. package/public/sw.js +66 -0
  38. package/public/themes.js +72 -0
  39. package/public/tool-card.js +317 -0
  40. package/public/tree-view.js +474 -0
  41. package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  42. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  43. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  44. package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  45. package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  46. package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  48. package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  49. package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  50. package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  51. package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  52. package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  53. package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  55. package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  57. package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  58. package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  59. package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  60. package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  61. package/public/vendor/katex/katex.min.css +1 -0
  62. package/public/vendor/katex/katex.min.js +1 -0
  63. package/public/voice-input.js +74 -0
  64. package/public/websocket-client.js +156 -0
  65. package/scripts/copy-katex.mjs +32 -0
  66. package/src/pi-extension/tau-tree.ts +71 -0
  67. package/src/public/app-main.ts +2190 -0
  68. package/src/public/app-types.ts +96 -0
  69. package/src/public/app.ts +1 -0
  70. package/src/public/command-palette.ts +53 -0
  71. package/src/public/dialogs.ts +251 -0
  72. package/src/public/file-browser.ts +224 -0
  73. package/src/public/launcher-panel.ts +68 -0
  74. package/src/public/launcher.ts +101 -0
  75. package/src/public/legacy-dom.d.ts +29 -0
  76. package/src/public/markdown.ts +372 -0
  77. package/src/public/message-renderer.ts +311 -0
  78. package/src/public/model-picker.ts +500 -0
  79. package/src/public/session-sidebar.ts +522 -0
  80. package/src/public/session-stats-card.ts +176 -0
  81. package/src/public/state.ts +96 -0
  82. package/src/public/sw.ts +79 -0
  83. package/src/public/themes.ts +73 -0
  84. package/src/public/tool-card.ts +375 -0
  85. package/src/public/tree-view.ts +527 -0
  86. package/src/public/voice-input.ts +98 -0
  87. package/src/public/websocket-client.ts +165 -0
  88. package/src/server/auth.ts +88 -0
  89. package/src/server/config.ts +88 -0
  90. package/src/server/model-utils.ts +122 -0
  91. package/src/server/server-main.ts +1004 -0
  92. package/src/server/sessions.ts +481 -0
  93. package/src/server/tau.ts +9 -0
  94. package/src/server/tree.ts +288 -0
  95. package/src/server/types.ts +68 -0
  96. package/tsconfig.json +3 -0
  97. package/tsconfig.public.json +15 -0
  98. package/tsconfig.server.json +16 -0
@@ -0,0 +1 @@
1
+ export {};
package/public/app.js ADDED
@@ -0,0 +1 @@
1
+ import './app-main.js';
@@ -0,0 +1,42 @@
1
+ export function setupCommandPalette(commands) {
2
+ // These elements are part of the app's static index.html shell, so they
3
+ // are present when this setup function runs; assert non-null at the query.
4
+ const commandBtn = document.getElementById('command-btn');
5
+ const commandPalette = document.getElementById('command-palette');
6
+ const commandPaletteOverlay = document.getElementById('command-palette-overlay');
7
+ const commandList = document.getElementById('command-list');
8
+ function open() {
9
+ commandList.innerHTML = '';
10
+ commands.forEach(cmd => {
11
+ const el = document.createElement('div');
12
+ el.className = 'command-item';
13
+ el.innerHTML = `
14
+ <div class="command-icon">${cmd.icon}</div>
15
+ <div>
16
+ <div class="command-label">${cmd.label}</div>
17
+ <div class="command-desc">${cmd.desc}</div>
18
+ </div>
19
+ `;
20
+ el.addEventListener('click', () => {
21
+ close();
22
+ cmd.action();
23
+ });
24
+ commandList.appendChild(el);
25
+ });
26
+ commandPalette.classList.remove('hidden');
27
+ commandPaletteOverlay.classList.remove('hidden');
28
+ }
29
+ function close() {
30
+ commandPalette.classList.add('hidden');
31
+ commandPaletteOverlay.classList.add('hidden');
32
+ }
33
+ function closeIfOpen() {
34
+ if (commandPalette.classList.contains('hidden'))
35
+ return false;
36
+ close();
37
+ return true;
38
+ }
39
+ commandBtn.addEventListener('click', open);
40
+ commandPaletteOverlay.addEventListener('click', close);
41
+ return { close, closeIfOpen };
42
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Dialogs - Handles extension UI dialogs
3
+ */
4
+ export class DialogHandler {
5
+ container;
6
+ wsClient;
7
+ getSessionId;
8
+ currentDialog;
9
+ currentRequest;
10
+ timeoutId;
11
+ onIdle;
12
+ constructor(container, wsClient, getSessionId = null) {
13
+ this.container = container;
14
+ this.wsClient = wsClient;
15
+ this.getSessionId = getSessionId;
16
+ this.currentDialog = null;
17
+ this.currentRequest = null;
18
+ this.timeoutId = null;
19
+ this.onIdle = null;
20
+ }
21
+ showSelect(request) {
22
+ this.cancelCurrentDialog(true);
23
+ const { id, title, options, timeout, sessionId } = request;
24
+ const dialog = document.createElement('div');
25
+ dialog.className = 'dialog';
26
+ dialog.innerHTML = `
27
+ <div class="dialog-title">${this.escapeHtml(title || 'Select an option')}</div>
28
+ <div class="dialog-options" id="dialog-options"></div>
29
+ <div class="dialog-actions">
30
+ <button id="dialog-cancel">Cancel</button>
31
+ </div>
32
+ `;
33
+ const optionsContainer = dialog.querySelector('#dialog-options');
34
+ (options || []).forEach((option) => {
35
+ const optionDiv = document.createElement('div');
36
+ optionDiv.className = 'dialog-option';
37
+ optionDiv.textContent = option;
38
+ optionDiv.onclick = () => {
39
+ this.respond(id, { value: option }, sessionId);
40
+ };
41
+ optionsContainer.appendChild(optionDiv);
42
+ });
43
+ dialog.querySelector('#dialog-cancel').onclick = () => {
44
+ this.respond(id, { cancelled: true }, sessionId);
45
+ };
46
+ this.showDialog(dialog, timeout, id, sessionId, request);
47
+ }
48
+ showConfirm(request) {
49
+ this.cancelCurrentDialog(true);
50
+ const { id, title, message, timeout, sessionId } = request;
51
+ const dialog = document.createElement('div');
52
+ dialog.className = 'dialog';
53
+ dialog.innerHTML = `
54
+ <div class="dialog-title">${this.escapeHtml(title || 'Confirm')}</div>
55
+ ${message ? `<div class="dialog-message">${this.escapeHtml(message)}</div>` : ''}
56
+ <div class="dialog-actions">
57
+ <button id="dialog-no">No</button>
58
+ <button id="dialog-yes">Yes</button>
59
+ </div>
60
+ `;
61
+ dialog.querySelector('#dialog-yes').onclick = () => {
62
+ this.respond(id, { confirmed: true }, sessionId);
63
+ };
64
+ dialog.querySelector('#dialog-no').onclick = () => {
65
+ this.respond(id, { confirmed: false }, sessionId);
66
+ };
67
+ this.showDialog(dialog, timeout, id, sessionId, request);
68
+ }
69
+ showInput(request) {
70
+ this.cancelCurrentDialog(true);
71
+ const { id, title, placeholder, timeout, sessionId } = request;
72
+ const dialog = document.createElement('div');
73
+ dialog.className = 'dialog';
74
+ dialog.innerHTML = `
75
+ <div class="dialog-title">${this.escapeHtml(title || 'Input')}</div>
76
+ <input type="text" class="dialog-input" id="dialog-input" placeholder="${this.escapeHtml(placeholder || '')}" />
77
+ <div class="dialog-actions">
78
+ <button id="dialog-cancel">Cancel</button>
79
+ <button id="dialog-submit">Submit</button>
80
+ </div>
81
+ `;
82
+ const input = dialog.querySelector('#dialog-input');
83
+ if (!input)
84
+ return;
85
+ const submit = () => {
86
+ const value = input.value.trim();
87
+ this.respond(id, value ? { value } : { cancelled: true }, sessionId);
88
+ };
89
+ input.addEventListener('keypress', (e) => {
90
+ if (e.key === 'Enter')
91
+ submit();
92
+ });
93
+ dialog.querySelector('#dialog-submit').onclick = submit;
94
+ dialog.querySelector('#dialog-cancel').onclick = () => {
95
+ this.respond(id, { cancelled: true }, sessionId);
96
+ };
97
+ this.showDialog(dialog, timeout, id, sessionId, request);
98
+ // Focus input after a short delay
99
+ setTimeout(() => input.focus(), 100);
100
+ }
101
+ showEditor(request) {
102
+ this.cancelCurrentDialog(true);
103
+ const { id, title, prefill, timeout, sessionId } = request;
104
+ const dialog = document.createElement('div');
105
+ dialog.className = 'dialog';
106
+ dialog.innerHTML = `
107
+ <div class="dialog-title">${this.escapeHtml(title || 'Editor')}</div>
108
+ <textarea class="dialog-textarea" id="dialog-textarea">${this.escapeHtml(prefill || '')}</textarea>
109
+ <div class="dialog-actions">
110
+ <button id="dialog-cancel">Cancel</button>
111
+ <button id="dialog-save">Save</button>
112
+ </div>
113
+ `;
114
+ const textarea = dialog.querySelector('#dialog-textarea');
115
+ if (!textarea)
116
+ return;
117
+ dialog.querySelector('#dialog-save').onclick = () => {
118
+ const value = textarea.value;
119
+ this.respond(id, value ? { value } : { cancelled: true }, sessionId);
120
+ };
121
+ dialog.querySelector('#dialog-cancel').onclick = () => {
122
+ this.respond(id, { cancelled: true }, sessionId);
123
+ };
124
+ this.showDialog(dialog, timeout, id, sessionId, request);
125
+ // Focus textarea after a short delay
126
+ setTimeout(() => textarea.focus(), 100);
127
+ }
128
+ showNotification(request) {
129
+ const { message, notifyType } = request;
130
+ // Create a temporary notification element
131
+ const notification = document.createElement('div');
132
+ notification.className = 'error-message';
133
+ notification.textContent = `${notifyType === 'error' ? '⚠️' : notifyType === 'warning' ? '⚠️' : 'ℹ️'} ${message}`;
134
+ // Add to messages container temporarily
135
+ const messagesContainer = document.getElementById('messages');
136
+ if (messagesContainer) {
137
+ messagesContainer.appendChild(notification);
138
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
139
+ // Remove after 5 seconds
140
+ setTimeout(() => {
141
+ notification.remove();
142
+ }, 5000);
143
+ }
144
+ }
145
+ showDialog(dialogElement, timeout, requestId, sessionId = null, request = null) {
146
+ this.currentDialog = dialogElement;
147
+ this.currentRequest = { id: requestId, sessionId, request };
148
+ this.container.innerHTML = '';
149
+ this.container.appendChild(dialogElement);
150
+ this.container.classList.remove('hidden');
151
+ // Set up timeout if specified
152
+ if (timeout) {
153
+ this.timeoutId = setTimeout(() => {
154
+ this.respond(requestId, { cancelled: true }, sessionId);
155
+ }, timeout);
156
+ }
157
+ }
158
+ cancelCurrentDialog(suppressIdle = false) {
159
+ if (!this.currentRequest?.id) {
160
+ this.clearCurrentDialog();
161
+ return;
162
+ }
163
+ const { id, sessionId } = this.currentRequest;
164
+ this.clearCurrentDialog();
165
+ this.wsClient.send({
166
+ type: 'extension_ui_response',
167
+ id,
168
+ sessionId: sessionId || this.getSessionId?.(),
169
+ cancelled: true,
170
+ });
171
+ if (!suppressIdle)
172
+ this.onIdle?.();
173
+ }
174
+ clearCurrentDialog() {
175
+ if (this.timeoutId) {
176
+ clearTimeout(this.timeoutId);
177
+ this.timeoutId = null;
178
+ }
179
+ this.container.innerHTML = '';
180
+ this.container.classList.add('hidden');
181
+ this.currentDialog = null;
182
+ this.currentRequest = null;
183
+ }
184
+ respond(id, response, sessionId = null) {
185
+ this.clearCurrentDialog();
186
+ this.wsClient.send({
187
+ type: 'extension_ui_response',
188
+ id,
189
+ sessionId: sessionId || this.getSessionId?.(),
190
+ ...response
191
+ });
192
+ this.onIdle?.();
193
+ }
194
+ escapeHtml(text) {
195
+ const div = document.createElement('div');
196
+ div.textContent = text;
197
+ return div.innerHTML;
198
+ }
199
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * File Browser — right sidebar file tree with drag-and-drop
3
+ */
4
+ const FILE_ICONS = {
5
+ // Folders
6
+ directory: '📁',
7
+ // Code
8
+ js: '📄', ts: '📄', jsx: '📄', tsx: '📄',
9
+ py: '🐍', rb: '💎', go: '📄', rs: '🦀',
10
+ // Web
11
+ html: '🌐', css: '🎨', svg: '🎨',
12
+ // Data
13
+ json: '📋', yaml: '📋', yml: '📋', toml: '📋',
14
+ xml: '📋', csv: '📋',
15
+ // Docs
16
+ md: '📝', txt: '📝', rst: '📝',
17
+ // Images
18
+ png: '🖼️', jpg: '🖼️', jpeg: '🖼️', gif: '🖼️',
19
+ webp: '🖼️', ico: '🖼️',
20
+ // Config
21
+ env: '🔒', gitignore: '🔒', lock: '🔒',
22
+ // Default
23
+ default: '📄',
24
+ };
25
+ export function getFileIcon(name, isDirectory) {
26
+ if (isDirectory)
27
+ return FILE_ICONS.directory;
28
+ const ext = name.split('.').pop()?.toLowerCase() || '';
29
+ return FILE_ICONS[ext] || FILE_ICONS.default;
30
+ }
31
+ function formatSize(bytes) {
32
+ if (bytes == null)
33
+ return '';
34
+ if (bytes < 1024)
35
+ return `${bytes}B`;
36
+ if (bytes < 1024 * 1024)
37
+ return `${(bytes / 1024).toFixed(0)}K`;
38
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
39
+ }
40
+ export class FileBrowser {
41
+ container;
42
+ pathEl;
43
+ messageInput;
44
+ onFileInserted;
45
+ getSessionId;
46
+ currentPath;
47
+ constructor(container, pathEl, messageInput, onFileInserted = null, getSessionId = null) {
48
+ this.container = container;
49
+ this.pathEl = pathEl;
50
+ this.messageInput = messageInput;
51
+ this.onFileInserted = onFileInserted;
52
+ this.getSessionId = getSessionId;
53
+ this.currentPath = null;
54
+ this.setupDropTarget();
55
+ }
56
+ async load(dirPath) {
57
+ this.container.innerHTML = '<div class="file-loading">Loading…</div>';
58
+ try {
59
+ const params = new URLSearchParams();
60
+ const sessionId = this.getSessionId?.();
61
+ if (!sessionId) {
62
+ this.currentPath = null;
63
+ this.pathEl.textContent = '';
64
+ this.pathEl.title = '';
65
+ this.container.innerHTML = '<div class="file-loading">Select a Tau tab to browse files</div>';
66
+ return;
67
+ }
68
+ params.set('sessionId', sessionId);
69
+ if (dirPath)
70
+ params.set('path', dirPath);
71
+ const qs = params.toString();
72
+ const url = qs ? `/api/files?${qs}` : '/api/files';
73
+ const res = await fetch(url);
74
+ const data = await res.json();
75
+ if (data.error) {
76
+ this.container.innerHTML = `<div class="file-loading">${data.error}</div>`;
77
+ return;
78
+ }
79
+ this.currentPath = data.path;
80
+ this.pathEl.textContent = data.path;
81
+ this.pathEl.title = data.path;
82
+ this.render(data.items);
83
+ }
84
+ catch (err) {
85
+ this.container.innerHTML = '<div class="file-loading">Failed to load</div>';
86
+ }
87
+ }
88
+ getParentPath() {
89
+ if (!this.currentPath)
90
+ return null;
91
+ const sep = this.currentPath.includes('\\') ? '\\' : '/';
92
+ const normalized = this.currentPath.endsWith(sep) ? this.currentPath.slice(0, -1) : this.currentPath;
93
+ const lastSep = normalized.lastIndexOf(sep);
94
+ if (lastSep <= 0)
95
+ return sep === '/' ? '/' : null;
96
+ const parent = normalized.slice(0, lastSep);
97
+ return /^[A-Za-z]:$/.test(parent) ? parent + sep : parent;
98
+ }
99
+ render(items) {
100
+ this.container.innerHTML = '';
101
+ if (items.length === 0) {
102
+ this.container.innerHTML = '<div class="file-loading">Empty directory</div>';
103
+ return;
104
+ }
105
+ for (const item of items) {
106
+ const el = document.createElement('div');
107
+ el.className = `file-item${item.isDirectory ? ' directory' : ''}`;
108
+ el.draggable = true;
109
+ el.dataset.path = item.path;
110
+ el.dataset.name = item.name;
111
+ el.dataset.isDirectory = String(item.isDirectory);
112
+ const icon = getFileIcon(item.name, item.isDirectory);
113
+ const size = item.isDirectory ? '' : formatSize(item.size);
114
+ el.innerHTML = `
115
+ <span class="file-icon">${icon}</span>
116
+ <span class="file-name" title="${item.name}">${item.name}</span>
117
+ ${size ? `<span class="file-size">${size}</span>` : ''}
118
+ `;
119
+ // Click: navigate directory or insert file path into input
120
+ el.addEventListener('click', () => {
121
+ if (item.isDirectory) {
122
+ this.load(item.path);
123
+ }
124
+ else {
125
+ this.insertPath(item.path);
126
+ }
127
+ });
128
+ // Double-click: open file natively
129
+ el.addEventListener('dblclick', (e) => {
130
+ e.preventDefault();
131
+ if (!item.isDirectory) {
132
+ this.openNatively(item.path);
133
+ }
134
+ });
135
+ // Drag start
136
+ el.addEventListener('dragstart', (e) => {
137
+ if (!e.dataTransfer)
138
+ return;
139
+ e.dataTransfer.setData('text/plain', item.path);
140
+ e.dataTransfer.effectAllowed = 'copy';
141
+ el.classList.add('dragging');
142
+ });
143
+ el.addEventListener('dragend', () => {
144
+ el.classList.remove('dragging');
145
+ });
146
+ this.container.appendChild(el);
147
+ }
148
+ }
149
+ async openNatively(filePath) {
150
+ try {
151
+ const sessionId = this.getSessionId?.();
152
+ if (!sessionId)
153
+ return;
154
+ await fetch('/api/open', {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({ filePath, sessionId }),
158
+ });
159
+ }
160
+ catch (err) {
161
+ console.error('[FileBrowser] Failed to open:', err);
162
+ }
163
+ }
164
+ insertPath(filePath) {
165
+ const input = this.messageInput;
166
+ const start = input.selectionStart ?? 0;
167
+ const end = input.selectionEnd ?? 0;
168
+ input.value = input.value.substring(0, start) + filePath + ' ' + input.value.substring(end);
169
+ input.selectionStart = input.selectionEnd = start + filePath.length + 1;
170
+ input.focus();
171
+ input.dispatchEvent(new Event('input'));
172
+ if (this.onFileInserted)
173
+ this.onFileInserted(filePath);
174
+ }
175
+ setupDropTarget() {
176
+ const input = this.messageInput;
177
+ input.addEventListener('dragover', (e) => {
178
+ e.preventDefault();
179
+ if (e.dataTransfer)
180
+ e.dataTransfer.dropEffect = 'copy';
181
+ input.classList.add('file-drop-hover');
182
+ });
183
+ input.addEventListener('dragleave', () => {
184
+ input.classList.remove('file-drop-hover');
185
+ });
186
+ input.addEventListener('drop', (e) => {
187
+ e.preventDefault();
188
+ input.classList.remove('file-drop-hover');
189
+ const filePath = e.dataTransfer?.getData('text/plain') ?? '';
190
+ // Accept Unix paths (/) and Windows paths (C:\ or C:/)
191
+ if (filePath && (filePath.startsWith('/') || /^[A-Za-z]:[\\\/]/.test(filePath))) {
192
+ this.insertPath(filePath);
193
+ }
194
+ });
195
+ }
196
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,13 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" fill="#f0efe9"/>
3
+ <!-- Grid: 8x8, each cell 40px, centered in 512 (offset: 96,96) -->
4
+ <!-- Top bar: 3 cells wide at row 2, cols 2-4 -->
5
+ <rect x="176" y="156" width="56" height="56" fill="#2d2d2d"/>
6
+ <rect x="232" y="156" width="56" height="56" fill="#2d2d2d"/>
7
+ <rect x="288" y="156" width="56" height="56" fill="#2d2d2d"/>
8
+ <!-- Stem: 1 cell wide at col 3, rows 3-4 -->
9
+ <rect x="232" y="212" width="56" height="56" fill="#2d2d2d"/>
10
+ <rect x="232" y="268" width="56" height="56" fill="#2d2d2d"/>
11
+ <!-- Curl: offset block at col 4, row 5 -->
12
+ <rect x="288" y="324" width="56" height="56" fill="#2d2d2d"/>
13
+ </svg>
Binary file