From 95246743c36486135eb71ba1c9cc2a3ba6103e96 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:17:03 -0700 Subject: [PATCH 1/7] fix(rwallet): batch CoinGecko requests to 1 address each (free tier limit) CoinGecko free tier now limits to 1 contract address per request. Process in batches of 3 concurrent single-address requests with 1.5s delay. Co-Authored-By: Claude Opus 4.6 --- modules/rwallet/lib/price-feed.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/modules/rwallet/lib/price-feed.ts b/modules/rwallet/lib/price-feed.ts index 3f5ead9..df1a8b1 100644 --- a/modules/rwallet/lib/price-feed.ts +++ b/modules/rwallet/lib/price-feed.ts @@ -74,7 +74,7 @@ export async function getNativePrice(chainId: string): Promise { return data?.[coinId]?.usd ?? 0; } -/** Fetch token prices for a batch of contract addresses on a chain */ +/** Fetch token prices for contract addresses on a chain (1 per request for free tier) */ export async function getTokenPrices( chainId: string, addresses: string[], @@ -82,16 +82,22 @@ export async function getTokenPrices( const platform = CHAIN_PLATFORM[chainId]; if (!platform || addresses.length === 0) return new Map(); - const lower = addresses.map((a) => a.toLowerCase()); - const data = await cgFetch( - `https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${lower.join(",")}&vs_currencies=usd`, - ); - + const lower = [...new Set(addresses.map((a) => a.toLowerCase()))]; const result = new Map(); - if (data) { - for (const addr of lower) { - if (data[addr]?.usd) result.set(addr, data[addr].usd); - } + + // CoinGecko free tier: 1 contract address per request, ~30 req/min + // Process in batches of 3 with a short delay between batches + const BATCH = 3; + for (let i = 0; i < lower.length; i += BATCH) { + const batch = lower.slice(i, i + BATCH); + const fetches = batch.map(async (addr) => { + const data = await cgFetch( + `https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${addr}&vs_currencies=usd`, + ); + if (data?.[addr]?.usd) result.set(addr, data[addr].usd); + }); + await Promise.allSettled(fetches); + if (i + BATCH < lower.length) await new Promise((r) => setTimeout(r, 1500)); } return result; } From 395623af662b9fee587e8b44f16a860e0c52f101 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:25:23 -0700 Subject: [PATCH 2/7] feat(rdesign): deploy KiCad & FreeCAD MCP as Docker sidecars Switch from broken StdioClientTransport (child process) to SSEClientTransport (HTTP to sidecar containers via supergateway). Both sidecars share rspace-files volume so generated CAD files (STEP, STL, Gerber, SVG) are directly servable without copying. Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 20 ++++++++++++ docker/freecad-mcp/Dockerfile | 27 ++++++++++++++++ docker/kicad-mcp/Dockerfile | 31 ++++++++++++++++++ server/cad-orchestrator.ts | 12 +++---- server/index.ts | 61 ++++++----------------------------- 5 files changed, 93 insertions(+), 58 deletions(-) create mode 100644 docker/freecad-mcp/Dockerfile create mode 100644 docker/kicad-mcp/Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index 1957fba..e3b6c37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -262,6 +262,26 @@ services: retries: 5 start_period: 10s + # ── KiCad MCP sidecar (PCB design via SSE) ── + kicad-mcp: + build: ./docker/kicad-mcp + container_name: kicad-mcp + restart: unless-stopped + volumes: + - rspace-files:/data/files + networks: + - rspace-internal + + # ── FreeCAD MCP sidecar (3D CAD via SSE) ── + freecad-mcp: + build: ./docker/freecad-mcp + container_name: freecad-mcp + restart: unless-stopped + volumes: + - rspace-files:/data/files + networks: + - rspace-internal + # ── Scribus noVNC (rDesign DTP workspace) ── scribus-novnc: build: diff --git a/docker/freecad-mcp/Dockerfile b/docker/freecad-mcp/Dockerfile new file mode 100644 index 0000000..ccd7a19 --- /dev/null +++ b/docker/freecad-mcp/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-slim + +# Install FreeCAD headless (freecad-cmd) and dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + freecad \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Set headless Qt/FreeCAD env +ENV QT_QPA_PLATFORM=offscreen +ENV DISPLAY="" +ENV FREECAD_USER_CONFIG=/tmp/.FreeCAD + +WORKDIR /app + +# Copy MCP server source +COPY freecad-mcp-server/ . + +# Install Node deps + supergateway (stdio→SSE bridge) +RUN npm install && npm install -g supergateway + +# Ensure generated files dir exists +RUN mkdir -p /data/files/generated + +EXPOSE 8808 + +CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808"] diff --git a/docker/kicad-mcp/Dockerfile b/docker/kicad-mcp/Dockerfile new file mode 100644 index 0000000..b803f0c --- /dev/null +++ b/docker/kicad-mcp/Dockerfile @@ -0,0 +1,31 @@ +FROM node:20-slim + +# Install KiCad, Python, and build dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + kicad \ + python3 \ + python3-pip \ + python3-venv \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Use SWIG backend (headless — no KiCad GUI needed) +ENV KICAD_BACKEND=swig + +WORKDIR /app + +# Copy MCP server source +COPY KiCAD-MCP-Server/ . + +# Install Node deps + supergateway (stdio→SSE bridge) +RUN npm install && npm install -g supergateway + +# Install Python requirements (Pillow, cairosvg, etc.) +RUN pip3 install --break-system-packages -r python/requirements.txt + +# Ensure generated files dir exists +RUN mkdir -p /data/files/generated + +EXPOSE 8809 + +CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809"] diff --git a/server/cad-orchestrator.ts b/server/cad-orchestrator.ts index d185940..f83a925 100644 --- a/server/cad-orchestrator.ts +++ b/server/cad-orchestrator.ts @@ -283,7 +283,7 @@ function extractPathFromText(text: string, extensions: string[]): string | null export const KICAD_SYSTEM_PROMPT = `You are a KiCad PCB design assistant. You have access to KiCad MCP tools to create real PCB designs. Follow this workflow: -1. create_project — Create a new KiCad project in /tmp/kicad-gen-/ +1. create_project — Create a new KiCad project in /data/files/generated/kicad-/ 2. search_symbols — Find component symbols in KiCad libraries (e.g. ESP32, BME280, capacitors, resistors) 3. add_schematic_component — Place each component on the schematic 4. add_schematic_net_label — Add net labels for connections @@ -296,7 +296,7 @@ Follow this workflow: 11. export_gerber, export_bom, export_pdf — Generate manufacturing outputs Important: -- Use /tmp/kicad-gen-${Date.now()}/ as the project directory +- Use /data/files/generated/kicad-${Date.now()}/ as the project directory - Search for real symbols before placing components - Add decoupling capacitors and pull-up resistors as needed - Set reasonable board outline dimensions @@ -307,16 +307,16 @@ Important: export const FREECAD_SYSTEM_PROMPT = `You are a FreeCAD parametric CAD assistant. You have access to FreeCAD MCP tools to create real 3D models. Follow this workflow: -1. execute_python_script — Create output directory: import os; os.makedirs("/tmp/freecad-gen-", exist_ok=True) +1. execute_python_script — Create output directory: import os; os.makedirs("/data/files/generated/freecad-", exist_ok=True) 2. Create base geometry using create_box, create_cylinder, or create_sphere 3. Use boolean_operation (union, cut, intersection) to combine shapes 4. list_objects to verify the model state 5. save_document to save the FreeCAD file -6. execute_python_script to export STEP: Part.export([obj], "/tmp/freecad-gen-/model.step") -7. execute_python_script to export STL: Mesh.export([obj], "/tmp/freecad-gen-/model.stl") +6. execute_python_script to export STEP: Part.export([obj], "/data/files/generated/freecad-/model.step") +7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-/model.stl") Important: -- Use /tmp/freecad-gen-${Date.now()}/ as the working directory +- Use /data/files/generated/freecad-${Date.now()}/ as the working directory - For hollow objects, create the outer shell then cut the inner volume - For complex shapes, build up from primitives with boolean operations - Wall thickness should be at least 1mm for 3D printing diff --git a/server/index.ts b/server/index.ts index b3ccb3b..c4ebeb6 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1145,20 +1145,6 @@ async function process3DGenJob(job: Gen3DJob) { // ── Image helpers ── -/** Copy a file from a tmp path to the served generated directory → return server-relative URL */ -async function copyToServed(srcPath: string): Promise { - try { - const srcFile = Bun.file(srcPath); - if (!(await srcFile.exists())) return null; - const basename = srcPath.split("/").pop() || `file-${Date.now()}`; - const dir = resolve(process.env.FILES_DIR || "./data/files", "generated"); - await Bun.write(resolve(dir, basename), srcFile); - return `/data/files/generated/${basename}`; - } catch { - return null; - } -} - /** Read a /data/files/generated/... path from disk → base64 */ async function readFileAsBase64(serverPath: string): Promise { const filename = serverPath.split("/").pop(); @@ -1653,22 +1639,18 @@ app.post("/api/blender-gen", async (c) => { } }); -// KiCAD PCB design — MCP stdio bridge +// KiCAD PCB design — MCP SSE bridge (sidecar container) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator"; -const KICAD_MCP_PATH = process.env.KICAD_MCP_PATH || "/home/jeffe/KiCAD-MCP-Server/dist/index.js"; +const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/sse"; let kicadClient: Client | null = null; async function getKicadClient(): Promise { if (kicadClient) return kicadClient; - const transport = new StdioClientTransport({ - command: "node", - args: [KICAD_MCP_PATH], - }); - + const transport = new SSEClientTransport(new URL(KICAD_MCP_URL)); const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" }); transport.onclose = () => { kicadClient = null; }; @@ -1705,21 +1687,7 @@ app.post("/api/kicad/generate", async (c) => { const orch = await runCadAgentLoop(client, KICAD_SYSTEM_PROMPT, enrichedPrompt, GEMINI_API_KEY); const result = assembleKicadResult(orch); - // Copy generated files to served directory - const filesToCopy = [ - { path: result.schematicSvg, key: "schematicSvg" }, - { path: result.boardSvg, key: "boardSvg" }, - { path: result.gerberUrl, key: "gerberUrl" }, - { path: result.bomUrl, key: "bomUrl" }, - { path: result.pdfUrl, key: "pdfUrl" }, - ]; - - for (const { path, key } of filesToCopy) { - if (path && path.startsWith("/tmp/")) { - const served = await copyToServed(path); - if (served) (result as any)[key] = served; - } - } + // Files are already on the shared /data/files volume — no copy needed return c.json({ schematic_svg: result.schematicSvg, @@ -1774,18 +1742,14 @@ app.post("/api/kicad/:action", async (c) => { } }); -// FreeCAD parametric CAD — MCP stdio bridge -const FREECAD_MCP_PATH = process.env.FREECAD_MCP_PATH || "/home/jeffe/freecad-mcp-server/build/index.js"; +// FreeCAD parametric CAD — MCP SSE bridge (sidecar container) +const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/sse"; let freecadClient: Client | null = null; async function getFreecadClient(): Promise { if (freecadClient) return freecadClient; - const transport = new StdioClientTransport({ - command: "node", - args: [FREECAD_MCP_PATH], - }); - + const transport = new SSEClientTransport(new URL(FREECAD_MCP_URL)); const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" }); transport.onclose = () => { freecadClient = null; }; @@ -1818,14 +1782,7 @@ app.post("/api/freecad/generate", async (c) => { const orch = await runCadAgentLoop(client, FREECAD_SYSTEM_PROMPT, prompt, GEMINI_API_KEY); const result = assembleFreecadResult(orch); - // Copy generated files to served directory - for (const key of ["stepUrl", "stlUrl"] as const) { - const path = result[key]; - if (path && path.startsWith("/tmp/")) { - const served = await copyToServed(path); - if (served) (result as any)[key] = served; - } - } + // Files are already on the shared /data/files volume — no copy needed return c.json({ preview_url: result.previewUrl, From 8ba14a0e153dad107688d06a438da64f8bf286da Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:25:48 -0700 Subject: [PATCH 3/7] fix(rwallet): skip spam filter when CoinGecko data unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert per-address batching (rate limit cascade). Track cgAvailable flag in cache — only apply spam filter when CoinGecko successfully returned data. Co-Authored-By: Claude Opus 4.6 --- modules/rwallet/lib/price-feed.ts | 40 +++++++++++++++---------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/modules/rwallet/lib/price-feed.ts b/modules/rwallet/lib/price-feed.ts index df1a8b1..86b9574 100644 --- a/modules/rwallet/lib/price-feed.ts +++ b/modules/rwallet/lib/price-feed.ts @@ -34,6 +34,7 @@ const NATIVE_COIN_ID: Record = { interface CacheEntry { prices: Map; // address (lowercase) → USD price nativePrice: number; + cgAvailable: boolean; // true if CoinGecko successfully returned token data ts: number; } @@ -74,32 +75,27 @@ export async function getNativePrice(chainId: string): Promise { return data?.[coinId]?.usd ?? 0; } -/** Fetch token prices for contract addresses on a chain (1 per request for free tier) */ +/** Fetch token prices for a batch of contract addresses on a chain */ export async function getTokenPrices( chainId: string, addresses: string[], -): Promise> { +): Promise<{ prices: Map; available: boolean }> { const platform = CHAIN_PLATFORM[chainId]; - if (!platform || addresses.length === 0) return new Map(); + if (!platform || addresses.length === 0) return { prices: new Map(), available: false }; const lower = [...new Set(addresses.map((a) => a.toLowerCase()))]; - const result = new Map(); + const prices = new Map(); - // CoinGecko free tier: 1 contract address per request, ~30 req/min - // Process in batches of 3 with a short delay between batches - const BATCH = 3; - for (let i = 0; i < lower.length; i += BATCH) { - const batch = lower.slice(i, i + BATCH); - const fetches = batch.map(async (addr) => { - const data = await cgFetch( - `https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${addr}&vs_currencies=usd`, - ); - if (data?.[addr]?.usd) result.set(addr, data[addr].usd); - }); - await Promise.allSettled(fetches); - if (i + BATCH < lower.length) await new Promise((r) => setTimeout(r, 1500)); + const data = await cgFetch( + `https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${lower.join(",")}&vs_currencies=usd`, + ); + if (data && !data.error_code) { + for (const addr of lower) { + if (data[addr]?.usd) prices.set(addr, data[addr].usd); + } + return { prices, available: true }; } - return result; + return { prices, available: false }; } /** Fetch and cache all prices for a chain (native + tokens) */ @@ -117,13 +113,14 @@ async function fetchChainPrices( const promise = (async (): Promise => { try { - const [nativePrice, tokenPrices] = await Promise.all([ + const [nativePrice, tokenResult] = await Promise.all([ getNativePrice(chainId), getTokenPrices(chainId, tokenAddresses), ]); const entry: CacheEntry = { - prices: tokenPrices, + prices: tokenResult.prices, nativePrice, + cgAvailable: tokenResult.available, ts: Date.now(), }; cache.set(chainId, entry); @@ -202,7 +199,8 @@ export async function enrichWithPrices( }; }); - if (options?.filterSpam) { + // Only filter spam when CoinGecko data is available to verify against + if (options?.filterSpam && priceData.cgAvailable) { return enriched.filter((b) => { // Native tokens always pass if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") return true; From 110b733f94475827d83b5a343a882a22cf060b14 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:29:21 -0700 Subject: [PATCH 4/7] fix(rnotes): Google Docs-like comment sidebar, fix suggestions + duplicate extensions - Fix duplicate tiptap extension warnings by disabling link/underline in StarterKit v3 (which now includes them by default) - Move comment panel from metaZone (destroyed by renderMeta) to dedicated comment sidebar next to the editor, Google Docs style - Add click-on-highlight to open comment thread in sidebar - New comment creation shows inline textarea with auto-focus - Fix suggestion plugin: pass view getter instead of broken state.view access - Improve comment panel styling: avatars, Google Docs yellow active border, cleaner thread layout, Ctrl+Enter to submit, Escape to cancel - Bump folk-notes-app cache version to v=7 Co-Authored-By: Claude Opus 4.6 --- modules/rnotes/components/comment-panel.ts | 244 +++++++++++++++--- modules/rnotes/components/folk-notes-app.ts | 119 +++++++-- .../rnotes/components/suggestion-plugin.ts | 8 +- modules/rnotes/mod.ts | 2 +- 4 files changed, 309 insertions(+), 64 deletions(-) diff --git a/modules/rnotes/components/comment-panel.ts b/modules/rnotes/components/comment-panel.ts index 341bb3d..a237e02 100644 --- a/modules/rnotes/components/comment-panel.ts +++ b/modules/rnotes/components/comment-panel.ts @@ -119,50 +119,128 @@ class NotesCommentPanel extends HTMLElement { return `${Math.floor(diff / 86400000)}d ago`; }; const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); - const { authorId: currentUserId } = this.getSessionInfo(); + const { authorId: currentUserId, authorName: currentUserName } = this.getSessionInfo(); + const initials = (name: string) => name.split(/\s+/).map(w => w[0] || '').join('').slice(0, 2).toUpperCase() || '?'; + const avatarColor = (id: string) => { + let h = 0; + for (let i = 0; i < id.length; i++) h = id.charCodeAt(i) + ((h << 5) - h); + return `hsl(${Math.abs(h) % 360}, 55%, 55%)`; + }; this.shadow.innerHTML = `
@@ -171,19 +249,43 @@ class NotesCommentPanel extends HTMLElement { ${threads.map(thread => { const reactions = thread.reactions || {}; const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0); + const isActive = thread.id === this._activeThreadId; + const hasMessages = thread.messages.length > 0; + const firstMsg = thread.messages[0]; + const authorName = firstMsg?.authorName || currentUserName; + const authorId = firstMsg?.authorId || currentUserId; + return ` -
+
- ${esc(thread.messages[0]?.authorName || 'Anonymous')} - ${timeAgo(thread.createdAt)} -
- ${thread.messages.map(msg => ` -
-
${esc(msg.authorName)}
-
${esc(msg.text)}
+
${initials(authorName)}
+
+ ${esc(authorName)} + ${timeAgo(thread.createdAt)}
- `).join('')} - ${thread.messages.length === 0 ? '
Click to add a comment...
' : ''} +
+ ${hasMessages ? ` +
${esc(firstMsg.text)}
+ ${thread.messages.slice(1).map(msg => ` +
+
+
${initials(msg.authorName)}
+ ${esc(msg.authorName)} + ${timeAgo(msg.createdAt)} +
+
${esc(msg.text)}
+
+ `).join('')} + ` : ` +
+ +
+ + +
+
+ `} + ${hasMessages && reactionEntries.length > 0 ? `
${reactionEntries.map(([emoji, users]) => ` @@ -193,19 +295,28 @@ class NotesCommentPanel extends HTMLElement {
${REACTION_EMOJIS.map(e => ``).join('')}
+ ` : ''} + ${hasMessages && thread.reminderAt ? `
- ${thread.reminderAt - ? `⏰ ${formatDate(thread.reminderAt)}` - : `` - } - + ⏰ ${formatDate(thread.reminderAt)} +
+ ` : ''} + ${hasMessages ? `
- +
+ +
+ ` : ''}
- + ${hasMessages ? ` + + + + ` : ''} +
`; @@ -214,12 +325,21 @@ class NotesCommentPanel extends HTMLElement { `; this.wireEvents(); + + // Auto-focus new comment textarea + requestAnimationFrame(() => { + const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement; + if (newInput) newInput.focus(); + }); } private wireEvents() { // Click thread to scroll editor to it this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => { el.addEventListener('click', (e) => { + // Don't handle clicks on inputs/buttons/textareas + const target = e.target as HTMLElement; + if (target.closest('input, textarea, button')) return; const threadId = (el as HTMLElement).dataset.thread; if (!threadId || !this._editor) return; this._activeThreadId = threadId; @@ -236,6 +356,46 @@ class NotesCommentPanel extends HTMLElement { }); }); + // New comment submit (thread with no messages yet) + this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const threadId = (btn as HTMLElement).dataset.submitNew; + if (!threadId) return; + const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement; + const text = textarea?.value?.trim(); + if (!text) return; + this.addReply(threadId, text); + }); + }); + + // New comment cancel — delete the empty thread + this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const threadId = (btn as HTMLElement).dataset.cancelNew; + if (threadId) this.deleteThread(threadId); + }); + }); + + // New comment textarea — Ctrl+Enter to submit, Escape to cancel + this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => { + textarea.addEventListener('keydown', (e) => { + const ke = e as KeyboardEvent; + if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) { + e.stopPropagation(); + const threadId = (textarea as HTMLTextAreaElement).dataset.newThread; + const text = (textarea as HTMLTextAreaElement).value.trim(); + if (threadId && text) this.addReply(threadId, text); + } else if (ke.key === 'Escape') { + e.stopPropagation(); + const threadId = (textarea as HTMLTextAreaElement).dataset.newThread; + if (threadId) this.deleteThread(threadId); + } + }); + textarea.addEventListener('click', (e) => e.stopPropagation()); + }); + // Reply this.shadow.querySelectorAll('[data-reply]').forEach(btn => { btn.addEventListener('click', (e) => { diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index 8397615..ba5d9e8 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -1216,14 +1216,17 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const useYjs = !isDemo && isEditable; this.contentZone.innerHTML = ` -

- - ${isEditable ? this.renderToolbar() : ''} -