From 0a5eceedf191ccdd800a7d60b38b7d99be412d16 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Thu, 15 Oct 2026 22:05:47 +0200 Subject: [PATCH 01/28] i18n(sv): translate 480 new strings Brings the Swedish catalog up to date with upstream main (eb741a3). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/sv.toml | 480 +++++++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index f7b2999d..195c94af 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -16,6 +16,7 @@ name = "Svenska" "1…9, 0" = "1…9, 0" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära tillgångar, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: åsidosätt deras värden per läge och lägg till egna variabler" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Ett ensamt lager justeras mot sin ram: markera fler lager, eller ett lager i en ram" "A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot" "A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge" "A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild" @@ -309,7 +310,9 @@ name = "Svenska" "Copy {value}" = "Kopiera {value}" "Corner radius" = "Hörnradie" "Corner smoothing" = "Hörnutjämning" +"Couldn't download the variables" = "Det gick inte att hämta variablerna" "Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns." +"Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}" "Count" = "Antal" "Create" = "Skapa" "Create a variable collection first" = "Skapa en variabelsamling först" @@ -474,6 +477,7 @@ name = "Svenska" "False" = "Falskt" "Feb" = "feb" "Fetching from the server…" = "Hämtar från servern…" +"FigCraft couldn't open its window: {error}" = "FigCraft kunde inte öppna sitt fönster: {error}" "File" = "Fil" "File name" = "Filnamn" "File not found" = "Filen hittades inte" @@ -593,6 +597,7 @@ name = "Svenska" "Hug contents" = "Omslut innehåll" "Icon components on a 24 px grid with keylines and a size preview" = "Ikonkomponenter på ett 24 px-rutnät med hjälplinjer och en förhandsgranskning av storlekar" "Icon set starter" = "Startpaket för ikonuppsättning" +"If it keeps happening, please report it at {link} with this message." = "Om det fortsätter att hända kan du rapportera det på {link} och bifoga det här meddelandet." "If" = "Om" "If {condition}" = "Om {condition}" "Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)" @@ -1045,6 +1050,7 @@ name = "Svenska" "Remove the else and its actions" = "Ta bort else-grenen och dess åtgärder" "Remove this condition and its actions" = "Ta bort det här villkoret och dess åtgärder" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Vill du ta bort den här filen från servern? Den kan inte längre delas eller öppnas därifrån. Din kopia förblir öppen här." +"Remove {name} from session" = "Ta bort {name} från sessionen" "Remove {name} from {server}?" = "Vill du ta bort {name} från {server}?" "Removed" = "Borttaget" "Removed from the server" = "Borttagen från servern" @@ -1112,6 +1118,7 @@ name = "Svenska" "Rulers" = "Linjaler" "Run" = "Kör" "Run {label}" = "Kör {label}" +"Save files automatically" = "Spara filer automatiskt" "Search templates" = "Sök mallar" "See all templates" = "Se alla mallar" "Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras" @@ -1388,6 +1395,8 @@ name = "Svenska" "Update from selection" = "Uppdatera från markering" "Updated {components} and {styles}" = "{components} och {styles} har uppdaterats" "Updated {name}" = "{name} har uppdaterats" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Det hjälper ofta att uppdatera grafikdrivrutinen. Om du bara vill använda OpenGL startar du FigCraft från en kommandotolk med:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Det hjälper ofta att uppdatera grafikdrivrutinen. WGPU_BACKEND (vulkan, gl, metal) anger ett annat grafik-API att prova." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Laddar upp filen till en FigCraft-server och håller fliken synkroniserad med den. Du får en länk för redigering och en för visning." "Uppercase" = "Versaler" "Use a fixed value" = "Använd ett fast värde" @@ -1430,6 +1439,7 @@ name = "Svenska" "Visibility" = "Synlighet" "Visible" = "Synlig" "Vivid" = "Livfull" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND är satt och anger vilket grafik-API som ska användas; tar du bort den väljer FigCraft själv." "Warm" = "Varm" "Watch" = "Bevaka" "Wavy" = "Vågig" @@ -1588,6 +1598,127 @@ name = "Svenska" [commands] +"align.bottom" = "align.bottom" +"align.hcenter" = "align.hcenter" +"align.left" = "align.left" +"align.right" = "align.right" +"align.top" = "align.top" +"align.vcenter" = "align.vcenter" +"annotation.add" = "annotation.add" +"annotation.addCategory" = "annotation.addCategory" +"annotation.categories" = "annotation.categories" +"annotation.edit" = "annotation.edit" +"annotation.editCategory" = "annotation.editCategory" +"annotation.list" = "annotation.list" +"annotation.remove" = "annotation.remove" +"annotation.removeCategory" = "annotation.removeCategory" +"app.actions" = "app.actions" +"app.preferences" = "app.preferences" +"arrange.back" = "arrange.back" +"arrange.backward" = "arrange.backward" +"arrange.forward" = "arrange.forward" +"arrange.front" = "arrange.front" +"arrange.packH" = "arrange.packH" +"arrange.packV" = "arrange.packV" +"arrange.roundToPixels" = "arrange.roundToPixels" +"arrange.tidy" = "arrange.tidy" +"bool.exclude" = "bool.exclude" +"bool.flatten" = "bool.flatten" +"bool.intersect" = "bool.intersect" +"bool.subtract" = "bool.subtract" +"bool.union" = "bool.union" +"branch.archive" = "branch.archive" +"branch.create" = "branch.create" +"branch.list" = "branch.list" +"branch.merge" = "branch.merge" +"branch.open" = "branch.open" +"branch.review" = "branch.review" +"branch.updateFromMain" = "branch.updateFromMain" +"codeConnect.export" = "codeConnect.export" +"codeConnect.import" = "codeConnect.import" +"codeConnect.remove" = "codeConnect.remove" +"codeConnect.set" = "codeConnect.set" +"collab.cursor" = "collab.cursor" +"collab.cursorChat" = "collab.cursorChat" +"collab.diff" = "collab.diff" +"collab.files" = "collab.files" +"collab.follow" = "collab.follow" +"collab.forgetFile" = "collab.forgetFile" +"collab.host" = "collab.host" +"collab.join" = "collab.join" +"collab.leave" = "collab.leave" +"collab.manageFile" = "collab.manageFile" +"collab.openFile" = "collab.openFile" +"collab.peers" = "collab.peers" +"collab.publish" = "collab.publish" +"collab.recoverFile" = "collab.recoverFile" +"collab.registerLinks" = "collab.registerLinks" +"collab.removePerson" = "collab.removePerson" +"collab.report" = "collab.report" +"collab.serverInfo" = "collab.serverInfo" +"collab.share" = "collab.share" +"collab.spotlight" = "collab.spotlight" +"collab.stop" = "collab.stop" +"command.batch" = "command.batch" +"comment.add" = "comment.add" +"comment.delete" = "comment.delete" +"comment.edit" = "comment.edit" +"comment.link" = "comment.link" +"comment.list" = "comment.list" +"comment.markRead" = "comment.markRead" +"comment.move" = "comment.move" +"comment.next" = "comment.next" +"comment.open" = "comment.open" +"comment.people" = "comment.people" +"comment.react" = "comment.react" +"comment.reply" = "comment.reply" +"comment.resolve" = "comment.resolve" +"comment.zoom" = "comment.zoom" +"component.addProperty" = "component.addProperty" +"component.addVariant" = "component.addVariant" +"component.bindProperty" = "component.bindProperty" +"component.combineVariants" = "component.combineVariants" +"component.create" = "component.create" +"component.createMultiple" = "component.createMultiple" +"component.deleteProperty" = "component.deleteProperty" +"component.detach" = "component.detach" +"component.editProperty" = "component.editProperty" +"component.exposeInstance" = "component.exposeInstance" +"component.goToMain" = "component.goToMain" +"component.hideWhenPublishing" = "component.hideWhenPublishing" +"component.list" = "component.list" +"component.makeSlot" = "component.makeSlot" +"component.playground" = "component.playground" +"component.properties" = "component.properties" +"component.pushChanges" = "component.pushChanges" +"component.removeSlot" = "component.removeSlot" +"component.resetInstance" = "component.resetInstance" +"component.resetOverride" = "component.resetOverride" +"component.restoreMain" = "component.restoreMain" +"dev.compare" = "dev.compare" +"dev.focus" = "dev.focus" +"dev.resolveChange" = "dev.resolveChange" +"dev.unfocus" = "dev.unfocus" +"devResource.add" = "devResource.add" +"devResource.edit" = "devResource.edit" +"devResource.list" = "devResource.list" +"devResource.remove" = "devResource.remove" +"distribute.horizontal" = "distribute.horizontal" +"distribute.vertical" = "distribute.vertical" +"doc.home" = "doc.home" +"doc.list" = "doc.list" +"doc.nextTab" = "doc.nextTab" +"doc.previousTab" = "doc.previousTab" +"doc.switch" = "doc.switch" +"edit.copy" = "edit.copy" +"edit.copyAsCss" = "edit.copyAsCss" +"edit.copyAsPng" = "edit.copyAsPng" +"edit.copyAsSvg" = "edit.copyAsSvg" +"edit.copyAsText" = "edit.copyAsText" +"edit.copyLink" = "edit.copyLink" +"edit.copyProperties" = "edit.copyProperties" +"edit.cut" = "edit.cut" +"edit.delete" = "edit.delete" "edit.duplicate" = "Duplicera" "edit.findReplace" = "Sök och ersätt" "edit.paste" = "Klistra in" @@ -1708,6 +1839,355 @@ name = "Svenska" "object.moveToPage" = "Flytta till sida" "object.opacityKeys" = "Nyckelbildrutor för opacitet" "object.rasterize" = "Rastrera" +"object.releaseMask" = "object.releaseMask" +"object.removeFill" = "object.removeFill" +"object.removeFrame" = "object.removeFrame" +"object.removeStroke" = "object.removeStroke" +"object.rename" = "object.rename" +"object.resizeToFit" = "object.resizeToFit" +"object.rotate180" = "object.rotate180" +"object.rotateLeft" = "object.rotateLeft" +"object.rotateRight" = "object.rotateRight" +"object.setThumbnail" = "object.setThumbnail" +"object.swapFillStroke" = "object.swapFillStroke" +"object.toggleLock" = "object.toggleLock" +"object.toggleVisible" = "object.toggleVisible" +"object.ungroup" = "object.ungroup" +"object.wrapInSection" = "object.wrapInSection" +"page.add" = "page.add" +"page.delete" = "page.delete" +"page.duplicate" = "page.duplicate" +"page.list" = "page.list" +"page.move" = "page.move" +"page.rename" = "page.rename" +"page.select" = "page.select" +"page.setBackground" = "page.setBackground" +"paint.documentColors" = "paint.documentColors" +"plugin.close" = "plugin.close" +"plugin.directory.install" = "plugin.directory.install" +"plugin.directory.list" = "plugin.directory.list" +"plugin.directory.publish" = "plugin.directory.publish" +"plugin.directory.unpublish" = "plugin.directory.unpublish" +"plugin.event" = "plugin.event" +"plugin.import" = "plugin.import" +"plugin.list" = "plugin.list" +"plugin.remove" = "plugin.remove" +"plugin.run" = "plugin.run" +"plugin.ui" = "plugin.ui" +"plugins.manage" = "plugins.manage" +"plugins.runLast" = "plugins.runLast" +"pref.autoSave" = "pref.autoSave" +"pref.highlightHover" = "pref.highlightHover" +"pref.keepTool" = "pref.keepTool" +"pref.language" = "pref.language" +"pref.nudge" = "pref.nudge" +"pref.numberKeysOpacity" = "pref.numberKeysOpacity" +"pref.publishServer" = "pref.publishServer" +"pref.reduceMotion" = "pref.reduceMotion" +"pref.renameDuplicates" = "pref.renameDuplicates" +"pref.showDimensions" = "pref.showDimensions" +"pref.userName" = "pref.userName" +"proto.action.add" = "proto.action.add" +"proto.action.move" = "proto.action.move" +"proto.action.remove" = "proto.action.remove" +"proto.action.set" = "proto.action.set" +"proto.closePreview" = "proto.closePreview" +"proto.conditional.addBlock" = "proto.conditional.addBlock" +"proto.conditional.removeBlock" = "proto.conditional.removeBlock" +"proto.conditional.setCondition" = "proto.conditional.setCondition" +"proto.evaluate" = "proto.evaluate" +"proto.flow.add" = "proto.flow.add" +"proto.flow.remove" = "proto.flow.remove" +"proto.flow.rename" = "proto.flow.rename" +"proto.interaction.add" = "proto.interaction.add" +"proto.interaction.clear" = "proto.interaction.clear" +"proto.interaction.remove" = "proto.interaction.remove" +"proto.interaction.set" = "proto.interaction.set" +"proto.present" = "proto.present" +"proto.presentOptions" = "proto.presentOptions" +"proto.preview" = "proto.preview" +"proto.settings" = "proto.settings" +"section.toggleContents" = "section.toggleContents" +"select.add" = "select.add" +"select.all" = "select.all" +"select.area" = "select.area" +"select.byName" = "select.byName" +"select.children" = "select.children" +"select.inverse" = "select.inverse" +"select.layersAt" = "select.layersAt" +"select.matching" = "select.matching" +"select.next" = "select.next" +"select.none" = "select.none" +"select.parent" = "select.parent" +"select.previous" = "select.previous" +"select.remove" = "select.remove" +"select.sameEffect" = "select.sameEffect" +"select.sameFill" = "select.sameFill" +"select.sameFont" = "select.sameFont" +"select.sameInstance" = "select.sameInstance" +"select.sameProperties" = "select.sameProperties" +"select.sameStroke" = "select.sameStroke" +"select.sameText" = "select.sameText" +"select.set" = "select.set" +"shape.arrow" = "shape.arrow" +"shape.ellipse" = "shape.ellipse" +"shape.line" = "shape.line" +"shape.polygon" = "shape.polygon" +"shape.rectangle" = "shape.rectangle" +"shape.star" = "shape.star" +"shapeBuilder.merge" = "shapeBuilder.merge" +"shapeBuilder.mode" = "shapeBuilder.mode" +"shapeBuilder.regions" = "shapeBuilder.regions" +"shapeBuilder.remove" = "shapeBuilder.remove" +"stroke.add" = "stroke.add" +"stroke.brush" = "stroke.brush" +"stroke.move" = "stroke.move" +"stroke.remove" = "stroke.remove" +"stroke.set" = "stroke.set" +"stroke.widthProfile" = "stroke.widthProfile" +"style.apply" = "style.apply" +"style.bind" = "style.bind" +"style.create" = "style.create" +"style.delete" = "style.delete" +"style.detach" = "style.detach" +"style.duplicate" = "style.duplicate" +"style.list" = "style.list" +"style.move" = "style.move" +"style.renameFolder" = "style.renameFolder" +"style.update" = "style.update" +"template.list" = "template.list" +"text.alignCenter" = "text.alignCenter" +"text.alignJustify" = "text.alignJustify" +"text.alignLeft" = "text.alignLeft" +"text.alignRight" = "text.alignRight" +"text.bold" = "text.bold" +"text.bulletList" = "text.bulletList" +"text.case" = "text.case" +"text.caseLower" = "text.caseLower" +"text.caseOriginal" = "text.caseOriginal" +"text.caseSmallCaps" = "text.caseSmallCaps" +"text.caseTitle" = "text.caseTitle" +"text.caseUpper" = "text.caseUpper" +"text.create" = "text.create" +"text.decoration" = "text.decoration" +"text.edit.begin" = "text.edit.begin" +"text.edit.caret" = "text.edit.caret" +"text.edit.end" = "text.edit.end" +"text.edit.insert" = "text.edit.insert" +"text.edit.key" = "text.edit.key" +"text.edit.preedit" = "text.edit.preedit" +"text.edit.selectLine" = "text.edit.selectLine" +"text.edit.selectWord" = "text.edit.selectWord" +"text.edit.selection" = "text.edit.selection" +"text.endPreview" = "text.endPreview" +"text.fonts" = "text.fonts" +"text.italic" = "text.italic" +"text.lineHeightDown" = "text.lineHeightDown" +"text.lineHeightUp" = "text.lineHeightUp" +"text.link" = "text.link" +"text.listIndent" = "text.listIndent" +"text.listOutdent" = "text.listOutdent" +"text.listStyle" = "text.listStyle" +"text.maxLines" = "text.maxLines" +"text.numberList" = "text.numberList" +"text.onPath" = "text.onPath" +"text.paragraphIndent" = "text.paragraphIndent" +"text.paragraphSpacing" = "text.paragraphSpacing" +"text.pathOptions" = "text.pathOptions" +"text.preview" = "text.preview" +"text.removeFromPath" = "text.removeFromPath" +"text.set" = "text.set" +"text.setFeatures" = "text.setFeatures" +"text.setVariations" = "text.setVariations" +"text.sizeDown" = "text.sizeDown" +"text.sizeUp" = "text.sizeUp" +"text.strikethrough" = "text.strikethrough" +"text.style" = "text.style" +"text.trackingDown" = "text.trackingDown" +"text.trackingUp" = "text.trackingUp" +"text.underline" = "text.underline" +"text.weightDown" = "text.weightDown" +"text.weightUp" = "text.weightUp" +"tool.arrow" = "tool.arrow" +"tool.comment" = "tool.comment" +"tool.ellipse" = "tool.ellipse" +"tool.frame" = "tool.frame" +"tool.hand" = "tool.hand" +"tool.line" = "tool.line" +"tool.move" = "tool.move" +"tool.pen" = "tool.pen" +"tool.pencil" = "tool.pencil" +"tool.pencilBrush" = "tool.pencilBrush" +"tool.place" = "tool.place" +"tool.polygon" = "tool.polygon" +"tool.rectangle" = "tool.rectangle" +"tool.scale" = "tool.scale" +"tool.scaleAnchor" = "tool.scaleAnchor" +"tool.section" = "tool.section" +"tool.set" = "tool.set" +"tool.shapeBuilder" = "tool.shapeBuilder" +"tool.slice" = "tool.slice" +"tool.star" = "tool.star" +"tool.text" = "tool.text" +"transform.move" = "transform.move" +"transform.moveTo" = "transform.moveTo" +"transform.nudge" = "transform.nudge" +"transform.nudgeSize" = "transform.nudgeSize" +"transform.resize" = "transform.resize" +"transform.rotate" = "transform.rotate" +"transform.rotationOrigin" = "transform.rotationOrigin" +"transform.scale" = "transform.scale" +"transform.setRotationOrigin" = "transform.setRotationOrigin" +"variable.addMode" = "variable.addMode" +"variable.bind" = "variable.bind" +"variable.create" = "variable.create" +"variable.createCollection" = "variable.createCollection" +"variable.delete" = "variable.delete" +"variable.deleteCollection" = "variable.deleteCollection" +"variable.duplicate" = "variable.duplicate" +"variable.export" = "variable.export" +"variable.import" = "variable.import" +"variable.list" = "variable.list" +"variable.moveMode" = "variable.moveMode" +"variable.removeMode" = "variable.removeMode" +"variable.renameCollection" = "variable.renameCollection" +"variable.renameMode" = "variable.renameMode" +"variable.resetOverride" = "variable.resetOverride" +"variable.set" = "variable.set" +"variable.setMode" = "variable.setMode" +"variable.update" = "variable.update" +"vector.create" = "vector.create" +"vector.deleteHeal" = "vector.deleteHeal" +"vector.edit.addVertex" = "vector.edit.addVertex" +"vector.edit.begin" = "vector.edit.begin" +"vector.edit.bend" = "vector.edit.bend" +"vector.edit.bounds" = "vector.edit.bounds" +"vector.edit.connect" = "vector.edit.connect" +"vector.edit.cornerRadius" = "vector.edit.cornerRadius" +"vector.edit.cut" = "vector.edit.cut" +"vector.edit.delete" = "vector.edit.delete" +"vector.edit.end" = "vector.edit.end" +"vector.edit.erase" = "vector.edit.erase" +"vector.edit.lasso" = "vector.edit.lasso" +"vector.edit.mirroring" = "vector.edit.mirroring" +"vector.edit.moveHandles" = "vector.edit.moveHandles" +"vector.edit.moveVertices" = "vector.edit.moveVertices" +"vector.edit.nudge" = "vector.edit.nudge" +"vector.edit.paint" = "vector.edit.paint" +"vector.edit.pen" = "vector.edit.pen" +"vector.edit.penEnd" = "vector.edit.penEnd" +"vector.edit.position" = "vector.edit.position" +"vector.edit.resize" = "vector.edit.resize" +"vector.edit.rotate" = "vector.edit.rotate" +"vector.edit.select" = "vector.edit.select" +"vector.edit.selectAll" = "vector.edit.selectAll" +"vector.edit.setHandle" = "vector.edit.setHandle" +"vector.edit.smooth" = "vector.edit.smooth" +"vector.edit.state" = "vector.edit.state" +"vector.edit.strokeCap" = "vector.edit.strokeCap" +"vector.edit.strokeJoin" = "vector.edit.strokeJoin" +"vector.edit.tool" = "vector.edit.tool" +"vector.edit.transform" = "vector.edit.transform" +"vector.edit.widthPoint" = "vector.edit.widthPoint" +"vector.flatten" = "vector.flatten" +"vector.join" = "vector.join" +"vector.offset" = "vector.offset" +"vector.outlineStroke" = "vector.outlineStroke" +"vector.reverse" = "vector.reverse" +"vector.simplify" = "vector.simplify" +"vector.smoothJoin" = "vector.smoothJoin" +"version.autosave" = "version.autosave" +"version.delete" = "version.delete" +"version.duplicate" = "version.duplicate" +"version.exitPreview" = "version.exitPreview" +"version.list" = "version.list" +"version.preview" = "version.preview" +"version.rename" = "version.rename" +"version.restore" = "version.restore" +"view.assets" = "view.assets" +"view.comments" = "view.comments" +"view.contextMenu" = "view.contextMenu" +"view.design" = "view.design" +"view.designPrototype" = "view.designPrototype" +"view.devMode" = "view.devMode" +"view.frameOutlines" = "view.frameOutlines" +"view.fullscreen" = "view.fullscreen" +"view.get" = "view.get" +"view.layers" = "view.layers" +"view.layoutGrids" = "view.layoutGrids" +"view.leftSidebar" = "view.leftSidebar" +"view.maskOutlines" = "view.maskOutlines" +"view.multiplayerCursors" = "view.multiplayerCursors" +"view.nextFrame" = "view.nextFrame" +"view.nextPage" = "view.nextPage" +"view.nextRegion" = "view.nextRegion" +"view.outlines" = "view.outlines" +"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" +"view.outlinesObjectBounds" = "view.outlinesObjectBounds" +"view.outlinesSelection" = "view.outlinesSelection" +"view.pan" = "view.pan" +"view.panelWidths" = "view.panelWidths" +"view.pixelGrid" = "view.pixelGrid" +"view.pixelPreview" = "view.pixelPreview" +"view.prevFrame" = "view.prevFrame" +"view.prevPage" = "view.prevPage" +"view.previousRegion" = "view.previousRegion" +"view.proofSrgb" = "view.proofSrgb" +"view.prototype" = "view.prototype" +"view.rulers" = "view.rulers" +"view.set" = "view.set" +"view.slices" = "view.slices" +"view.snapGeometry" = "view.snapGeometry" +"view.snapObjects" = "view.snapObjects" +"view.snapPixel" = "view.snapPixel" +"view.themeDark" = "view.themeDark" +"view.themeLight" = "view.themeLight" +"view.themeSystem" = "view.themeSystem" +"view.toggleUi" = "view.toggleUi" +"view.zoom100" = "view.zoom100" +"view.zoom200" = "view.zoom200" +"view.zoom50" = "view.zoom50" +"view.zoomAt" = "view.zoomAt" +"view.zoomFit" = "view.zoomFit" +"view.zoomIn" = "view.zoomIn" +"view.zoomOut" = "view.zoomOut" +"view.zoomSelection" = "view.zoomSelection" +"widget.click" = "widget.click" +"widget.insert" = "widget.insert" +"widget.list" = "widget.list" +"window.focus" = "window.focus" +"window.new" = "window.new" [menu] +"bool.exclude" = "bool.exclude" +"bool.flatten" = "bool.flatten" +"bool.intersect" = "bool.intersect" +"bool.subtract" = "bool.subtract" +"bool.union" = "bool.union" +"file.duplicate" = "file.duplicate" +"file.import" = "file.import" +"file.rename" = "file.rename" +"libraries.open" = "libraries.open" +"select.sameEffect" = "select.sameEffect" +"select.sameFill" = "select.sameFill" +"select.sameFont" = "select.sameFont" +"select.sameInstance" = "select.sameInstance" +"select.sameProperties" = "select.sameProperties" +"select.sameStroke" = "select.sameStroke" +"select.sameText" = "select.sameText" +"text.alignCenter" = "text.alignCenter" +"text.alignJustify" = "text.alignJustify" +"text.alignLeft" = "text.alignLeft" +"text.alignRight" = "text.alignRight" +"view.assets" = "view.assets" +"view.design" = "view.design" +"view.layers" = "view.layers" +"view.outlines" = "view.outlines" +"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" +"view.outlinesObjectBounds" = "view.outlinesObjectBounds" +"view.outlinesSelection" = "view.outlinesSelection" +"view.prototype" = "view.prototype" +"view.themeDark" = "view.themeDark" +"view.themeLight" = "view.themeLight" +"view.themeSystem" = "view.themeSystem" From 5d1c24d0615696e590ba57fc7076776f773b1592 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 02:19:42 +0200 Subject: [PATCH 02/28] Contributors: add my name for the About window --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index bc8cf71b..ba7977a8 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -44,3 +44,8 @@ sync_github_name = true [people.volkovskey] real_name = "Andrii Volkov" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false From 8538dd740f58e51ce672ac6ff8674673e683cef3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:38:36 +0200 Subject: [PATCH 03/28] Refuse edits that leave a layer with non-finite geometry Found while testing crates/engine/src/cmd/arrange.rs:132 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/finite_geometry_tests.rs | 40 ++++++++++++++++++++++ crates/engine/src/lib.rs | 21 +++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/finite_geometry_tests.rs diff --git a/crates/engine/src/finite_geometry_tests.rs b/crates/engine/src/finite_geometry_tests.rs new file mode 100644 index 00000000..416b250c --- /dev/null +++ b/crates/engine/src/finite_geometry_tests.rs @@ -0,0 +1,40 @@ +//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be +//! reopened (JSON writes NaN and infinity as null). + +use serde_json::json; + +use crate::Session; + +fn rect(s: &mut Session) -> crate::NodeId { + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap(); + crate::params::id_of(&r["id"]).unwrap() +} + +fn assert_roundtrips(s: &Session) { + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens"); +} + +#[test] +fn huge_moves_are_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok(); + let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308})); + assert!(second.is_err(), "a move past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform); + assert_roundtrips(&s); +} + +#[test] +fn huge_scale_is_refused_and_the_file_still_reopens() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok(); + let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})); + assert!(second.is_err(), "a size past the f64 range is refused"); + let n = s.doc().unwrap().node(r).unwrap(); + assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height); + assert_roundtrips(&s); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..18498146 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -878,7 +878,8 @@ impl Session { let result = guard::run(id, || (spec.run)(self, params)) .and_then(|v| self.check_instance_structure(id, &before).map(|()| v)) .and_then(|v| self.check_preview(id, &before).map(|()| v)) - .and_then(|v| self.check_live_access(id, &before).map(|()| v)); + .and_then(|v| self.check_live_access(id, &before).map(|()| v)) + .and_then(|v| self.check_finite_geometry(id, &before).map(|()| v)); match &result { Ok(_) => { self.after_command(spec, before); @@ -916,6 +917,22 @@ impl Session { Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into())) } + /// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file + /// holding them saves but can't be reopened. Refuse such an edit (it is rolled back). + fn check_finite_geometry(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { + let Some((bdoc, _, _, uid)) = before else { return Ok(()) }; + let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) }; + if Arc::ptr_eq(&ds.doc, bdoc) { + return Ok(()); + } + let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite()); + let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b)); + match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) { + Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))), + None => Ok(()), + } + } + /// A live session's document you may only view (or that is on its way to a server) is /// read-only: refuse an edit (it is rolled back). fn check_live_access(&self, id: &str, before: &Option<(Arc, Vec, NodeId, u64)>) -> Result<()> { @@ -1528,6 +1545,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod finite_geometry_tests; +#[cfg(test)] mod frame_guides_tests; #[cfg(test)] mod frame_label_tests; From a00b2e0583ccf628a8d5c4c1fb0524efb87eb7ec Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Fri, 16 Oct 2026 05:42:07 +0200 Subject: [PATCH 04/28] i18n(sv): translate the command and menu labels The [commands] and [menu] rows held their command id as the text ("align.bottom" = "align.bottom"), which the menus and the command palette would show as is. They now translate the English labels the code gives those ids (spec! and m! in crates/engine), e.g. "align.bottom" = "Justera nederkant". Also the interface strings that are new on upstream main (23bdebd). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/sv.toml | 949 +++++++++++++++++---------------- 1 file changed, 479 insertions(+), 470 deletions(-) diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 195c94af..4fdf723f 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -66,6 +66,7 @@ name = "Svenska" "Add variant with a new value" = "Lägg till variant med ett nytt värde" "Added" = "Tillagd" "Added the library \"{name}\"" = "Biblioteket ”{name}” har lagts till" +"Adding to {name}" = "Lägger till i {name}" "Adjust" = "Justera" "Adjustment presets" = "Justeringsförinställningar" "Advanced" = "Avancerat" @@ -407,6 +408,9 @@ name = "Svenska" "Draw" = "Rita" "Draw a frame" = "Rita en ram" "Drawing tools" = "Ritverktyg" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .json- eller .svg-fil eller en bildfil." +"Drop to import" = "Släpp för att importera" +"Drop to open" = "Släpp för att öppna" "Drop shadow" = "Släppskugga" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dubbellicensierad under MIT OR Apache-2.0. Skapad av ArtCraft-teamet och gemenskapen." "Duo" = "Duo" @@ -900,6 +904,8 @@ name = "Svenska" "Open prototype preview" = "Öppna prototypförhandsgranskning" "Open variables ({count})" = "Öppna variabler ({count})" "Opened “{name}” in a new tab" = "Öppnade ”{name}” i en ny flik" +"Opening the file…" = "Öppnar filen…" +"Opening {n} files as new files" = { one = "Öppnar som en ny fil", other = "Öppnar {n} filer som nya filer" } "Open|comments" = "Öppna" "Options" = "Alternativ" "Orange" = "Orange" @@ -1320,6 +1326,9 @@ name = "Svenska" "Texts" = "Texter" "Texture" = "Textur" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Arbetsytan förhandsgranskar färger i sRGB; exporterade PNG-, JPG- och WebP-filer märks med Display P3." +"The destination document is unavailable. Nothing was imported." = "Måldokumentet är inte tillgängligt. Ingenting importerades." +"The destination document was closed. Nothing was imported." = "Måldokumentet stängdes. Ingenting importerades." +"The destination page was removed. Nothing was imported." = "Målsidan togs bort. Ingenting importerades." "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Filen raderas från servern: ingen kan längre öppna eller ansluta till den därifrån, och detta kan inte ångras. Kopior som är öppna finns kvar hos dem som har dem." "The file now holds another library" = "Filen innehåller nu ett annat bibliotek" "The file now holds another library (\"{name}\")" = "Filen innehåller nu ett annat bibliotek (\"{name}\")" @@ -1598,127 +1607,127 @@ name = "Svenska" [commands] -"align.bottom" = "align.bottom" -"align.hcenter" = "align.hcenter" -"align.left" = "align.left" -"align.right" = "align.right" -"align.top" = "align.top" -"align.vcenter" = "align.vcenter" -"annotation.add" = "annotation.add" -"annotation.addCategory" = "annotation.addCategory" -"annotation.categories" = "annotation.categories" -"annotation.edit" = "annotation.edit" -"annotation.editCategory" = "annotation.editCategory" -"annotation.list" = "annotation.list" -"annotation.remove" = "annotation.remove" -"annotation.removeCategory" = "annotation.removeCategory" -"app.actions" = "app.actions" -"app.preferences" = "app.preferences" -"arrange.back" = "arrange.back" -"arrange.backward" = "arrange.backward" -"arrange.forward" = "arrange.forward" -"arrange.front" = "arrange.front" -"arrange.packH" = "arrange.packH" -"arrange.packV" = "arrange.packV" -"arrange.roundToPixels" = "arrange.roundToPixels" -"arrange.tidy" = "arrange.tidy" -"bool.exclude" = "bool.exclude" -"bool.flatten" = "bool.flatten" -"bool.intersect" = "bool.intersect" -"bool.subtract" = "bool.subtract" -"bool.union" = "bool.union" -"branch.archive" = "branch.archive" -"branch.create" = "branch.create" -"branch.list" = "branch.list" -"branch.merge" = "branch.merge" -"branch.open" = "branch.open" -"branch.review" = "branch.review" -"branch.updateFromMain" = "branch.updateFromMain" -"codeConnect.export" = "codeConnect.export" -"codeConnect.import" = "codeConnect.import" -"codeConnect.remove" = "codeConnect.remove" -"codeConnect.set" = "codeConnect.set" -"collab.cursor" = "collab.cursor" -"collab.cursorChat" = "collab.cursorChat" -"collab.diff" = "collab.diff" -"collab.files" = "collab.files" -"collab.follow" = "collab.follow" -"collab.forgetFile" = "collab.forgetFile" -"collab.host" = "collab.host" -"collab.join" = "collab.join" -"collab.leave" = "collab.leave" -"collab.manageFile" = "collab.manageFile" -"collab.openFile" = "collab.openFile" -"collab.peers" = "collab.peers" -"collab.publish" = "collab.publish" -"collab.recoverFile" = "collab.recoverFile" -"collab.registerLinks" = "collab.registerLinks" -"collab.removePerson" = "collab.removePerson" -"collab.report" = "collab.report" -"collab.serverInfo" = "collab.serverInfo" -"collab.share" = "collab.share" -"collab.spotlight" = "collab.spotlight" -"collab.stop" = "collab.stop" -"command.batch" = "command.batch" -"comment.add" = "comment.add" -"comment.delete" = "comment.delete" -"comment.edit" = "comment.edit" -"comment.link" = "comment.link" -"comment.list" = "comment.list" -"comment.markRead" = "comment.markRead" -"comment.move" = "comment.move" -"comment.next" = "comment.next" -"comment.open" = "comment.open" -"comment.people" = "comment.people" -"comment.react" = "comment.react" -"comment.reply" = "comment.reply" -"comment.resolve" = "comment.resolve" -"comment.zoom" = "comment.zoom" -"component.addProperty" = "component.addProperty" -"component.addVariant" = "component.addVariant" -"component.bindProperty" = "component.bindProperty" -"component.combineVariants" = "component.combineVariants" -"component.create" = "component.create" -"component.createMultiple" = "component.createMultiple" -"component.deleteProperty" = "component.deleteProperty" -"component.detach" = "component.detach" -"component.editProperty" = "component.editProperty" -"component.exposeInstance" = "component.exposeInstance" -"component.goToMain" = "component.goToMain" -"component.hideWhenPublishing" = "component.hideWhenPublishing" -"component.list" = "component.list" -"component.makeSlot" = "component.makeSlot" -"component.playground" = "component.playground" -"component.properties" = "component.properties" -"component.pushChanges" = "component.pushChanges" -"component.removeSlot" = "component.removeSlot" -"component.resetInstance" = "component.resetInstance" -"component.resetOverride" = "component.resetOverride" -"component.restoreMain" = "component.restoreMain" -"dev.compare" = "dev.compare" -"dev.focus" = "dev.focus" -"dev.resolveChange" = "dev.resolveChange" -"dev.unfocus" = "dev.unfocus" -"devResource.add" = "devResource.add" -"devResource.edit" = "devResource.edit" -"devResource.list" = "devResource.list" -"devResource.remove" = "devResource.remove" -"distribute.horizontal" = "distribute.horizontal" -"distribute.vertical" = "distribute.vertical" -"doc.home" = "doc.home" -"doc.list" = "doc.list" -"doc.nextTab" = "doc.nextTab" -"doc.previousTab" = "doc.previousTab" -"doc.switch" = "doc.switch" -"edit.copy" = "edit.copy" -"edit.copyAsCss" = "edit.copyAsCss" -"edit.copyAsPng" = "edit.copyAsPng" -"edit.copyAsSvg" = "edit.copyAsSvg" -"edit.copyAsText" = "edit.copyAsText" -"edit.copyLink" = "edit.copyLink" -"edit.copyProperties" = "edit.copyProperties" -"edit.cut" = "edit.cut" -"edit.delete" = "edit.delete" +"align.bottom" = "Justera nederkant" +"align.hcenter" = "Justera horisontella mittpunkter" +"align.left" = "Justera vänster" +"align.right" = "Justera höger" +"align.top" = "Justera överkant" +"align.vcenter" = "Justera vertikala mittpunkter" +"annotation.add" = "Lägg till anteckning" +"annotation.addCategory" = "Lägg till anteckningskategori" +"annotation.categories" = "Anteckningskategorier" +"annotation.edit" = "Redigera anteckning" +"annotation.editCategory" = "Redigera anteckningskategori" +"annotation.list" = "Lista anteckningar" +"annotation.remove" = "Ta bort anteckning" +"annotation.removeCategory" = "Radera anteckningskategori" +"app.actions" = "Åtgärder" +"app.preferences" = "Inställningar" +"arrange.back" = "Flytta längst bak" +"arrange.backward" = "Flytta bakåt" +"arrange.forward" = "Flytta framåt" +"arrange.front" = "Flytta längst fram" +"arrange.packH" = "Packa horisontellt" +"arrange.packV" = "Packa vertikalt" +"arrange.roundToPixels" = "Avrunda till pixlar" +"arrange.tidy" = "Snygga till" +"bool.exclude" = "Uteslut markering" +"bool.flatten" = "Platta till boolesk form" +"bool.intersect" = "Skärning av markering" +"bool.subtract" = "Subtrahera markering" +"bool.union" = "Förena markering" +"branch.archive" = "Arkivera gren" +"branch.create" = "Skapa gren" +"branch.list" = "Lista grenar" +"branch.merge" = "Sammanfoga gren" +"branch.open" = "Öppna gren" +"branch.review" = "Granska gren" +"branch.updateFromMain" = "Uppdatera från main" +"codeConnect.export" = "Exportera kodkopplingar" +"codeConnect.import" = "Importera kodkopplingar" +"codeConnect.remove" = "Koppla från koden" +"codeConnect.set" = "Koppla till kod" +"collab.cursor" = "Visa markör vid" +"collab.cursorChat" = "Markörchatt" +"collab.diff" = "Visa synkändringar" +"collab.files" = "Dina onlinefiler" +"collab.follow" = "Följ" +"collab.forgetFile" = "Glöm onlinefil" +"collab.host" = "Starta livesession" +"collab.join" = "Gå med i livesession" +"collab.leave" = "Lämna livesession" +"collab.manageFile" = "Hantera publicerad fil" +"collab.openFile" = "Öppna onlinefil" +"collab.peers" = "Personer i den här filen" +"collab.publish" = "Publicera på server" +"collab.recoverFile" = "Återställ onlinefil" +"collab.registerLinks" = "Öppna inbjudningslänkar i FigCraft" +"collab.removePerson" = "Ta bort från session" +"collab.report" = "Rapportera filen till serveroperatören" +"collab.serverInfo" = "Serverinfo" +"collab.share" = "Dela med en anslutningskod" +"collab.spotlight" = "Rikta strålkastaren mot mig" +"collab.stop" = "Stoppa livesession" +"command.batch" = "Kör flera kommandon" +"comment.add" = "Lägg till kommentar" +"comment.delete" = "Ta bort kommentar" +"comment.edit" = "Redigera kommentar" +"comment.link" = "Kopiera länk till kommentar" +"comment.list" = "Lista kommentarer" +"comment.markRead" = "Markera kommentar som läst" +"comment.move" = "Flytta kommentar" +"comment.next" = "Nästa kommentar" +"comment.open" = "Öppna kommentar" +"comment.people" = "Personer i kommentarer" +"comment.react" = "Reagera på kommentar" +"comment.reply" = "Svara på kommentar" +"comment.resolve" = "Lös kommentar" +"comment.zoom" = "Zooma till kommentarer" +"component.addProperty" = "Lägg till komponentegenskap" +"component.addVariant" = "Lägg till variant" +"component.bindProperty" = "Koppla lager till egenskap" +"component.combineVariants" = "Kombinera som varianter" +"component.create" = "Skapa komponent" +"component.createMultiple" = "Skapa flera komponenter" +"component.deleteProperty" = "Radera komponentegenskap" +"component.detach" = "Koppla bort instans" +"component.editProperty" = "Redigera komponentegenskap" +"component.exposeInstance" = "Exponera kapslad instans" +"component.goToMain" = "Gå till huvudkomponent" +"component.hideWhenPublishing" = "Dölj vid publicering" +"component.list" = "Lista komponenter" +"component.makeSlot" = "Gör till plats" +"component.playground" = "Komponenttestyta" +"component.properties" = "Komponentegenskaper" +"component.pushChanges" = "Skicka ändringar till huvudkomponenten" +"component.removeSlot" = "Ta bort plats" +"component.resetInstance" = "Återställ alla ändringar" +"component.resetOverride" = "Återställ åsidosättning" +"component.restoreMain" = "Återställ huvudkomponent" +"dev.compare" = "Jämför ändringar" +"dev.focus" = "Fokusvy" +"dev.resolveChange" = "Lös ändring" +"dev.unfocus" = "Lämna fokusvy" +"devResource.add" = "Lägg till utvecklarresurs" +"devResource.edit" = "Redigera utvecklarresurs" +"devResource.list" = "Lista utvecklarresurser" +"devResource.remove" = "Ta bort utvecklarresurs" +"distribute.horizontal" = "Fördela horisontellt avstånd" +"distribute.vertical" = "Fördela vertikalt avstånd" +"doc.home" = "Start" +"doc.list" = "Lista dokument" +"doc.nextTab" = "Nästa flik" +"doc.previousTab" = "Föregående flik" +"doc.switch" = "Byt dokument" +"edit.copy" = "Kopiera" +"edit.copyAsCss" = "Kopiera som CSS" +"edit.copyAsPng" = "Kopiera som PNG" +"edit.copyAsSvg" = "Kopiera som SVG" +"edit.copyAsText" = "Kopiera som text" +"edit.copyLink" = "Kopiera länk till markering" +"edit.copyProperties" = "Kopiera egenskaper" +"edit.cut" = "Klipp ut" +"edit.delete" = "Radera" "edit.duplicate" = "Duplicera" "edit.findReplace" = "Sök och ersätt" "edit.paste" = "Klistra in" @@ -1839,355 +1848,355 @@ name = "Svenska" "object.moveToPage" = "Flytta till sida" "object.opacityKeys" = "Nyckelbildrutor för opacitet" "object.rasterize" = "Rastrera" -"object.releaseMask" = "object.releaseMask" -"object.removeFill" = "object.removeFill" -"object.removeFrame" = "object.removeFrame" -"object.removeStroke" = "object.removeStroke" -"object.rename" = "object.rename" -"object.resizeToFit" = "object.resizeToFit" -"object.rotate180" = "object.rotate180" -"object.rotateLeft" = "object.rotateLeft" -"object.rotateRight" = "object.rotateRight" -"object.setThumbnail" = "object.setThumbnail" -"object.swapFillStroke" = "object.swapFillStroke" -"object.toggleLock" = "object.toggleLock" -"object.toggleVisible" = "object.toggleVisible" -"object.ungroup" = "object.ungroup" -"object.wrapInSection" = "object.wrapInSection" -"page.add" = "page.add" -"page.delete" = "page.delete" -"page.duplicate" = "page.duplicate" -"page.list" = "page.list" -"page.move" = "page.move" -"page.rename" = "page.rename" -"page.select" = "page.select" -"page.setBackground" = "page.setBackground" -"paint.documentColors" = "paint.documentColors" -"plugin.close" = "plugin.close" -"plugin.directory.install" = "plugin.directory.install" -"plugin.directory.list" = "plugin.directory.list" -"plugin.directory.publish" = "plugin.directory.publish" -"plugin.directory.unpublish" = "plugin.directory.unpublish" -"plugin.event" = "plugin.event" -"plugin.import" = "plugin.import" -"plugin.list" = "plugin.list" -"plugin.remove" = "plugin.remove" -"plugin.run" = "plugin.run" -"plugin.ui" = "plugin.ui" -"plugins.manage" = "plugins.manage" -"plugins.runLast" = "plugins.runLast" -"pref.autoSave" = "pref.autoSave" -"pref.highlightHover" = "pref.highlightHover" -"pref.keepTool" = "pref.keepTool" -"pref.language" = "pref.language" -"pref.nudge" = "pref.nudge" -"pref.numberKeysOpacity" = "pref.numberKeysOpacity" -"pref.publishServer" = "pref.publishServer" -"pref.reduceMotion" = "pref.reduceMotion" -"pref.renameDuplicates" = "pref.renameDuplicates" -"pref.showDimensions" = "pref.showDimensions" -"pref.userName" = "pref.userName" -"proto.action.add" = "proto.action.add" -"proto.action.move" = "proto.action.move" -"proto.action.remove" = "proto.action.remove" -"proto.action.set" = "proto.action.set" -"proto.closePreview" = "proto.closePreview" -"proto.conditional.addBlock" = "proto.conditional.addBlock" -"proto.conditional.removeBlock" = "proto.conditional.removeBlock" -"proto.conditional.setCondition" = "proto.conditional.setCondition" -"proto.evaluate" = "proto.evaluate" -"proto.flow.add" = "proto.flow.add" -"proto.flow.remove" = "proto.flow.remove" -"proto.flow.rename" = "proto.flow.rename" -"proto.interaction.add" = "proto.interaction.add" -"proto.interaction.clear" = "proto.interaction.clear" -"proto.interaction.remove" = "proto.interaction.remove" -"proto.interaction.set" = "proto.interaction.set" -"proto.present" = "proto.present" -"proto.presentOptions" = "proto.presentOptions" -"proto.preview" = "proto.preview" -"proto.settings" = "proto.settings" -"section.toggleContents" = "section.toggleContents" -"select.add" = "select.add" -"select.all" = "select.all" -"select.area" = "select.area" -"select.byName" = "select.byName" -"select.children" = "select.children" -"select.inverse" = "select.inverse" -"select.layersAt" = "select.layersAt" -"select.matching" = "select.matching" -"select.next" = "select.next" -"select.none" = "select.none" -"select.parent" = "select.parent" -"select.previous" = "select.previous" -"select.remove" = "select.remove" -"select.sameEffect" = "select.sameEffect" -"select.sameFill" = "select.sameFill" -"select.sameFont" = "select.sameFont" -"select.sameInstance" = "select.sameInstance" -"select.sameProperties" = "select.sameProperties" -"select.sameStroke" = "select.sameStroke" -"select.sameText" = "select.sameText" -"select.set" = "select.set" -"shape.arrow" = "shape.arrow" -"shape.ellipse" = "shape.ellipse" -"shape.line" = "shape.line" -"shape.polygon" = "shape.polygon" -"shape.rectangle" = "shape.rectangle" -"shape.star" = "shape.star" -"shapeBuilder.merge" = "shapeBuilder.merge" -"shapeBuilder.mode" = "shapeBuilder.mode" -"shapeBuilder.regions" = "shapeBuilder.regions" -"shapeBuilder.remove" = "shapeBuilder.remove" -"stroke.add" = "stroke.add" -"stroke.brush" = "stroke.brush" -"stroke.move" = "stroke.move" -"stroke.remove" = "stroke.remove" -"stroke.set" = "stroke.set" -"stroke.widthProfile" = "stroke.widthProfile" -"style.apply" = "style.apply" -"style.bind" = "style.bind" -"style.create" = "style.create" -"style.delete" = "style.delete" -"style.detach" = "style.detach" -"style.duplicate" = "style.duplicate" -"style.list" = "style.list" -"style.move" = "style.move" -"style.renameFolder" = "style.renameFolder" -"style.update" = "style.update" -"template.list" = "template.list" -"text.alignCenter" = "text.alignCenter" -"text.alignJustify" = "text.alignJustify" -"text.alignLeft" = "text.alignLeft" -"text.alignRight" = "text.alignRight" -"text.bold" = "text.bold" -"text.bulletList" = "text.bulletList" -"text.case" = "text.case" -"text.caseLower" = "text.caseLower" -"text.caseOriginal" = "text.caseOriginal" -"text.caseSmallCaps" = "text.caseSmallCaps" -"text.caseTitle" = "text.caseTitle" -"text.caseUpper" = "text.caseUpper" -"text.create" = "text.create" -"text.decoration" = "text.decoration" -"text.edit.begin" = "text.edit.begin" -"text.edit.caret" = "text.edit.caret" -"text.edit.end" = "text.edit.end" -"text.edit.insert" = "text.edit.insert" -"text.edit.key" = "text.edit.key" -"text.edit.preedit" = "text.edit.preedit" -"text.edit.selectLine" = "text.edit.selectLine" -"text.edit.selectWord" = "text.edit.selectWord" -"text.edit.selection" = "text.edit.selection" -"text.endPreview" = "text.endPreview" -"text.fonts" = "text.fonts" -"text.italic" = "text.italic" -"text.lineHeightDown" = "text.lineHeightDown" -"text.lineHeightUp" = "text.lineHeightUp" -"text.link" = "text.link" -"text.listIndent" = "text.listIndent" -"text.listOutdent" = "text.listOutdent" -"text.listStyle" = "text.listStyle" -"text.maxLines" = "text.maxLines" -"text.numberList" = "text.numberList" -"text.onPath" = "text.onPath" -"text.paragraphIndent" = "text.paragraphIndent" -"text.paragraphSpacing" = "text.paragraphSpacing" -"text.pathOptions" = "text.pathOptions" -"text.preview" = "text.preview" -"text.removeFromPath" = "text.removeFromPath" -"text.set" = "text.set" -"text.setFeatures" = "text.setFeatures" -"text.setVariations" = "text.setVariations" -"text.sizeDown" = "text.sizeDown" -"text.sizeUp" = "text.sizeUp" -"text.strikethrough" = "text.strikethrough" -"text.style" = "text.style" -"text.trackingDown" = "text.trackingDown" -"text.trackingUp" = "text.trackingUp" -"text.underline" = "text.underline" -"text.weightDown" = "text.weightDown" -"text.weightUp" = "text.weightUp" -"tool.arrow" = "tool.arrow" -"tool.comment" = "tool.comment" -"tool.ellipse" = "tool.ellipse" -"tool.frame" = "tool.frame" -"tool.hand" = "tool.hand" -"tool.line" = "tool.line" -"tool.move" = "tool.move" -"tool.pen" = "tool.pen" -"tool.pencil" = "tool.pencil" -"tool.pencilBrush" = "tool.pencilBrush" -"tool.place" = "tool.place" -"tool.polygon" = "tool.polygon" -"tool.rectangle" = "tool.rectangle" -"tool.scale" = "tool.scale" -"tool.scaleAnchor" = "tool.scaleAnchor" -"tool.section" = "tool.section" -"tool.set" = "tool.set" -"tool.shapeBuilder" = "tool.shapeBuilder" -"tool.slice" = "tool.slice" -"tool.star" = "tool.star" -"tool.text" = "tool.text" -"transform.move" = "transform.move" -"transform.moveTo" = "transform.moveTo" -"transform.nudge" = "transform.nudge" -"transform.nudgeSize" = "transform.nudgeSize" -"transform.resize" = "transform.resize" -"transform.rotate" = "transform.rotate" -"transform.rotationOrigin" = "transform.rotationOrigin" -"transform.scale" = "transform.scale" -"transform.setRotationOrigin" = "transform.setRotationOrigin" -"variable.addMode" = "variable.addMode" -"variable.bind" = "variable.bind" -"variable.create" = "variable.create" -"variable.createCollection" = "variable.createCollection" -"variable.delete" = "variable.delete" -"variable.deleteCollection" = "variable.deleteCollection" -"variable.duplicate" = "variable.duplicate" -"variable.export" = "variable.export" -"variable.import" = "variable.import" -"variable.list" = "variable.list" -"variable.moveMode" = "variable.moveMode" -"variable.removeMode" = "variable.removeMode" -"variable.renameCollection" = "variable.renameCollection" -"variable.renameMode" = "variable.renameMode" -"variable.resetOverride" = "variable.resetOverride" -"variable.set" = "variable.set" -"variable.setMode" = "variable.setMode" -"variable.update" = "variable.update" -"vector.create" = "vector.create" -"vector.deleteHeal" = "vector.deleteHeal" -"vector.edit.addVertex" = "vector.edit.addVertex" -"vector.edit.begin" = "vector.edit.begin" -"vector.edit.bend" = "vector.edit.bend" -"vector.edit.bounds" = "vector.edit.bounds" -"vector.edit.connect" = "vector.edit.connect" -"vector.edit.cornerRadius" = "vector.edit.cornerRadius" -"vector.edit.cut" = "vector.edit.cut" -"vector.edit.delete" = "vector.edit.delete" -"vector.edit.end" = "vector.edit.end" -"vector.edit.erase" = "vector.edit.erase" -"vector.edit.lasso" = "vector.edit.lasso" -"vector.edit.mirroring" = "vector.edit.mirroring" -"vector.edit.moveHandles" = "vector.edit.moveHandles" -"vector.edit.moveVertices" = "vector.edit.moveVertices" -"vector.edit.nudge" = "vector.edit.nudge" -"vector.edit.paint" = "vector.edit.paint" -"vector.edit.pen" = "vector.edit.pen" -"vector.edit.penEnd" = "vector.edit.penEnd" -"vector.edit.position" = "vector.edit.position" -"vector.edit.resize" = "vector.edit.resize" -"vector.edit.rotate" = "vector.edit.rotate" -"vector.edit.select" = "vector.edit.select" -"vector.edit.selectAll" = "vector.edit.selectAll" -"vector.edit.setHandle" = "vector.edit.setHandle" -"vector.edit.smooth" = "vector.edit.smooth" -"vector.edit.state" = "vector.edit.state" -"vector.edit.strokeCap" = "vector.edit.strokeCap" -"vector.edit.strokeJoin" = "vector.edit.strokeJoin" -"vector.edit.tool" = "vector.edit.tool" -"vector.edit.transform" = "vector.edit.transform" -"vector.edit.widthPoint" = "vector.edit.widthPoint" -"vector.flatten" = "vector.flatten" -"vector.join" = "vector.join" -"vector.offset" = "vector.offset" -"vector.outlineStroke" = "vector.outlineStroke" -"vector.reverse" = "vector.reverse" -"vector.simplify" = "vector.simplify" -"vector.smoothJoin" = "vector.smoothJoin" -"version.autosave" = "version.autosave" -"version.delete" = "version.delete" -"version.duplicate" = "version.duplicate" -"version.exitPreview" = "version.exitPreview" -"version.list" = "version.list" -"version.preview" = "version.preview" -"version.rename" = "version.rename" -"version.restore" = "version.restore" -"view.assets" = "view.assets" -"view.comments" = "view.comments" -"view.contextMenu" = "view.contextMenu" -"view.design" = "view.design" -"view.designPrototype" = "view.designPrototype" -"view.devMode" = "view.devMode" -"view.frameOutlines" = "view.frameOutlines" -"view.fullscreen" = "view.fullscreen" -"view.get" = "view.get" -"view.layers" = "view.layers" -"view.layoutGrids" = "view.layoutGrids" -"view.leftSidebar" = "view.leftSidebar" -"view.maskOutlines" = "view.maskOutlines" -"view.multiplayerCursors" = "view.multiplayerCursors" -"view.nextFrame" = "view.nextFrame" -"view.nextPage" = "view.nextPage" -"view.nextRegion" = "view.nextRegion" -"view.outlines" = "view.outlines" -"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" -"view.outlinesObjectBounds" = "view.outlinesObjectBounds" -"view.outlinesSelection" = "view.outlinesSelection" -"view.pan" = "view.pan" -"view.panelWidths" = "view.panelWidths" -"view.pixelGrid" = "view.pixelGrid" -"view.pixelPreview" = "view.pixelPreview" -"view.prevFrame" = "view.prevFrame" -"view.prevPage" = "view.prevPage" -"view.previousRegion" = "view.previousRegion" -"view.proofSrgb" = "view.proofSrgb" -"view.prototype" = "view.prototype" -"view.rulers" = "view.rulers" -"view.set" = "view.set" -"view.slices" = "view.slices" -"view.snapGeometry" = "view.snapGeometry" -"view.snapObjects" = "view.snapObjects" -"view.snapPixel" = "view.snapPixel" -"view.themeDark" = "view.themeDark" -"view.themeLight" = "view.themeLight" -"view.themeSystem" = "view.themeSystem" -"view.toggleUi" = "view.toggleUi" -"view.zoom100" = "view.zoom100" -"view.zoom200" = "view.zoom200" -"view.zoom50" = "view.zoom50" -"view.zoomAt" = "view.zoomAt" -"view.zoomFit" = "view.zoomFit" -"view.zoomIn" = "view.zoomIn" -"view.zoomOut" = "view.zoomOut" -"view.zoomSelection" = "view.zoomSelection" -"widget.click" = "widget.click" -"widget.insert" = "widget.insert" -"widget.list" = "widget.list" -"window.focus" = "window.focus" -"window.new" = "window.new" +"object.releaseMask" = "Frigör mask" +"object.removeFill" = "Ta bort fyllning" +"object.removeFrame" = "Ta bort ram" +"object.removeStroke" = "Ta bort kontur" +"object.rename" = "Byt namn" +"object.resizeToFit" = "Anpassa storlek" +"object.rotate180" = "Rotera 180°" +"object.rotateLeft" = "Rotera 90° åt vänster" +"object.rotateRight" = "Rotera 90° åt höger" +"object.setThumbnail" = "Ange som miniatyr" +"object.swapFillStroke" = "Byt plats på fyllning och kontur" +"object.toggleLock" = "Lås/lås upp markering" +"object.toggleVisible" = "Visa/dölj markering" +"object.ungroup" = "Dela upp markerade grupper" +"object.wrapInSection" = "Lägg i nytt avsnitt" +"page.add" = "Lägg till sida" +"page.delete" = "Radera sida" +"page.duplicate" = "Duplicera sida" +"page.list" = "Lista sidor" +"page.move" = "Flytta sida" +"page.rename" = "Byt namn på sida" +"page.select" = "Gå till sida" +"page.setBackground" = "Sidbakgrund" +"paint.documentColors" = "Dokumentfärger" +"plugin.close" = "Stäng plugin-panelen" +"plugin.directory.install" = "Installera plugin från katalogen" +"plugin.directory.list" = "Bläddra i plugin-katalogen" +"plugin.directory.publish" = "Publicera plugin i katalogen" +"plugin.directory.unpublish" = "Avpublicera plugin från katalogen" +"plugin.event" = "Använd kontroll i plugin-panelen" +"plugin.import" = "Importera plugin från manifest…" +"plugin.list" = "Lista plugin-program" +"plugin.remove" = "Ta bort plugin" +"plugin.run" = "Kör plugin" +"plugin.ui" = "Plugin-panel" +"plugins.manage" = "Hantera plugin-program…" +"plugins.runLast" = "Kör senaste plugin" +"pref.autoSave" = "Spara filer automatiskt" +"pref.highlightHover" = "Framhäv lager vid hovring" +"pref.keepTool" = "Behåll verktyget markerat efter användning" +"pref.language" = "Gränssnittsspråk" +"pref.nudge" = "Knuffavstånd" +"pref.numberKeysOpacity" = "Använd siffertangenterna för opacitet" +"pref.publishServer" = "Server för delning online" +"pref.reduceMotion" = "Minska rörelse" +"pref.renameDuplicates" = "Byt namn på duplicerade lager" +"pref.showDimensions" = "Visa dimensioner på objekt" +"pref.userName" = "Ditt namn i kommentarer" +"proto.action.add" = "Lägg till prototypåtgärd" +"proto.action.move" = "Ändra ordning på prototypåtgärd" +"proto.action.remove" = "Ta bort prototypåtgärd" +"proto.action.set" = "Redigera prototypåtgärd" +"proto.closePreview" = "Stäng prototypförhandsgranskning" +"proto.conditional.addBlock" = "Lägg till annars om / annars" +"proto.conditional.removeBlock" = "Ta bort om / annars om / annars" +"proto.conditional.setCondition" = "Redigera villkor" +"proto.evaluate" = "Beräkna prototyputtryck" +"proto.flow.add" = "Lägg till startpunkt för flöde" +"proto.flow.remove" = "Ta bort flöde" +"proto.flow.rename" = "Byt namn på flöde" +"proto.interaction.add" = "Lägg till interaktion" +"proto.interaction.clear" = "Ta bort alla interaktioner" +"proto.interaction.remove" = "Ta bort interaktion" +"proto.interaction.set" = "Redigera interaktion" +"proto.present" = "Presentera" +"proto.presentOptions" = "Presentationsalternativ" +"proto.preview" = "Öppna prototypförhandsgranskning" +"proto.settings" = "Prototypinställningar" +"section.toggleContents" = "Dölj/visa avsnittets innehåll" +"select.add" = "Lägg till i markering" +"select.all" = "Markera alla" +"select.area" = "Markera område" +"select.byName" = "Markera lager efter namn" +"select.children" = "Markera underordnade" +"select.inverse" = "Invertera markering" +"select.layersAt" = "Lager vid en punkt" +"select.matching" = "Markera matchande lager" +"select.next" = "Markera nästa syskon" +"select.none" = "Markera inga" +"select.parent" = "Markera överordnad" +"select.previous" = "Markera föregående syskon" +"select.remove" = "Ta bort från markering" +"select.sameEffect" = "Markera samma effekt" +"select.sameFill" = "Markera samma fyllning" +"select.sameFont" = "Markera samma teckensnitt" +"select.sameInstance" = "Markera samma instans" +"select.sameProperties" = "Markera samma egenskaper" +"select.sameStroke" = "Markera samma kontur" +"select.sameText" = "Markera samma textegenskaper" +"select.set" = "Markera" +"shape.arrow" = "Pil" +"shape.ellipse" = "Ellips" +"shape.line" = "Linje" +"shape.polygon" = "Polygon" +"shape.rectangle" = "Rektangel" +"shape.star" = "Stjärna" +"shapeBuilder.merge" = "Sammanfoga områden" +"shapeBuilder.mode" = "Formbyggarläge" +"shapeBuilder.regions" = "Formbyggarområden" +"shapeBuilder.remove" = "Ta bort områden" +"stroke.add" = "Lägg till kontur" +"stroke.brush" = "Pensel" +"stroke.move" = "Flytta kontur" +"stroke.remove" = "Ta bort kontur" +"stroke.set" = "Ange kontur" +"stroke.widthProfile" = "Breddprofil" +"style.apply" = "Använd stil" +"style.bind" = "Bind variabel i stil" +"style.create" = "Skapa stil" +"style.delete" = "Ta bort stil" +"style.detach" = "Koppla bort stil" +"style.duplicate" = "Duplicera stil" +"style.list" = "Lista stilar" +"style.move" = "Flytta stilar" +"style.renameFolder" = "Byt namn på stilmapp" +"style.update" = "Redigera stil" +"template.list" = "Lista mallar" +"text.alignCenter" = "Centrera" +"text.alignJustify" = "Marginaljustera" +"text.alignLeft" = "Justera vänster" +"text.alignRight" = "Justera höger" +"text.bold" = "Fet" +"text.bulletList" = "Punktlista" +"text.case" = "Skiftläge" +"text.caseLower" = "Gemener" +"text.caseOriginal" = "Ursprungligt skiftläge" +"text.caseSmallCaps" = "Kapitäler" +"text.caseTitle" = "Inledande versaler" +"text.caseUpper" = "Versaler" +"text.create" = "Text" +"text.decoration" = "Dekoration" +"text.edit.begin" = "Redigera text" +"text.edit.caret" = "Placera markören" +"text.edit.end" = "Sluta redigera text" +"text.edit.insert" = "Skriv text" +"text.edit.key" = "Texttangent" +"text.edit.preedit" = "Komponera text (IME)" +"text.edit.selectLine" = "Markera textrad" +"text.edit.selectWord" = "Markera ord" +"text.edit.selection" = "Textmarkering" +"text.endPreview" = "Avsluta förhandsgranskning av text" +"text.fonts" = "Lista teckensnitt" +"text.italic" = "Kursiv" +"text.lineHeightDown" = "Minska radhöjden" +"text.lineHeightUp" = "Öka radhöjden" +"text.link" = "Skapa länk" +"text.listIndent" = "Öka listindrag" +"text.listOutdent" = "Minska listindrag" +"text.listStyle" = "Liststil" +"text.maxLines" = "Max antal rader" +"text.numberList" = "Numrerad lista" +"text.onPath" = "Text på bana" +"text.paragraphIndent" = "Styckeindrag" +"text.paragraphSpacing" = "Styckeavstånd" +"text.pathOptions" = "Alternativ för textbana" +"text.preview" = "Förhandsgranska textändring" +"text.removeFromPath" = "Ta bort text från bana" +"text.set" = "Ange text" +"text.setFeatures" = "OpenType-funktioner" +"text.setVariations" = "Axlar för variabla teckensnitt" +"text.sizeDown" = "Minska teckenstorleken" +"text.sizeUp" = "Öka teckenstorleken" +"text.strikethrough" = "Genomstruken" +"text.style" = "Formatera textomfång" +"text.trackingDown" = "Minska teckenavståndet" +"text.trackingUp" = "Öka teckenavståndet" +"text.underline" = "Understrykning" +"text.weightDown" = "Minska teckenvikten" +"text.weightUp" = "Öka teckenvikten" +"tool.arrow" = "Pil" +"tool.comment" = "Kommentar" +"tool.ellipse" = "Ellips" +"tool.frame" = "Ram" +"tool.hand" = "Hand" +"tool.line" = "Linje" +"tool.move" = "Flytta" +"tool.pen" = "Penna" +"tool.pencil" = "Blyertspenna" +"tool.pencilBrush" = "Blyertspensel" +"tool.place" = "Placera med verktyget" +"tool.polygon" = "Polygon" +"tool.rectangle" = "Rektangel" +"tool.scale" = "Skala" +"tool.scaleAnchor" = "Skalningsankare" +"tool.section" = "Avsnitt" +"tool.set" = "Välj verktyg" +"tool.shapeBuilder" = "Formbyggare" +"tool.slice" = "Skiva" +"tool.star" = "Stjärna" +"tool.text" = "Text" +"transform.move" = "Flytta" +"transform.moveTo" = "Flytta till" +"transform.nudge" = "Knuffa" +"transform.nudgeSize" = "Ändra storlek genom att knuffa" +"transform.resize" = "Ändra storlek" +"transform.rotate" = "Rotera" +"transform.rotationOrigin" = "Flytta rotationspunkt" +"transform.scale" = "Skala" +"transform.setRotationOrigin" = "Ange rotationspunkt" +"variable.addMode" = "Lägg till läge" +"variable.bind" = "Bind variabel" +"variable.create" = "Skapa variabel" +"variable.createCollection" = "Skapa variabelsamling" +"variable.delete" = "Radera variabel" +"variable.deleteCollection" = "Radera samling" +"variable.duplicate" = "Duplicera variabel" +"variable.export" = "Exportera variabler" +"variable.import" = "Importera variabler" +"variable.list" = "Lista variabler" +"variable.moveMode" = "Flytta läge" +"variable.removeMode" = "Radera läge" +"variable.renameCollection" = "Byt namn på samling" +"variable.renameMode" = "Byt namn på läge" +"variable.resetOverride" = "Återställ åsidosatt variabel" +"variable.set" = "Ange variabelvärde" +"variable.setMode" = "Ange variabelläge" +"variable.update" = "Redigera variabel" +"vector.create" = "Vektor" +"vector.deleteHeal" = "Radera och läk markering" +"vector.edit.addVertex" = "Lägg till punkt" +"vector.edit.begin" = "Redigera vektor" +"vector.edit.bend" = "Böj segment" +"vector.edit.bounds" = "Markerade punkters gränser" +"vector.edit.connect" = "Koppla ihop punkter" +"vector.edit.cornerRadius" = "Hörnradie för punkt" +"vector.edit.cut" = "Dela bana" +"vector.edit.delete" = "Radera punkter" +"vector.edit.end" = "Klar med redigeringen" +"vector.edit.erase" = "Radera" +"vector.edit.lasso" = "Lassomarkering" +"vector.edit.mirroring" = "Handtagsspegling" +"vector.edit.moveHandles" = "Flytta handtag" +"vector.edit.moveVertices" = "Flytta punkter" +"vector.edit.nudge" = "Knuffa punkter" +"vector.edit.paint" = "Färghink" +"vector.edit.pen" = "Pennpunkt" +"vector.edit.penEnd" = "Avsluta pennbana" +"vector.edit.position" = "Punktposition" +"vector.edit.resize" = "Ändra storlek på punkter" +"vector.edit.rotate" = "Rotera punkter" +"vector.edit.select" = "Markera punkter" +"vector.edit.selectAll" = "Markera alla punkter" +"vector.edit.setHandle" = "Ange handtag" +"vector.edit.smooth" = "Gör punkter mjuka eller skarpa" +"vector.edit.state" = "Tillstånd för vektorredigering" +"vector.edit.strokeCap" = "Konturände för punkt" +"vector.edit.strokeJoin" = "Konturhörn för punkt" +"vector.edit.tool" = "Vektorredigeringsverktyg" +"vector.edit.transform" = "Transformera punkter" +"vector.edit.widthPoint" = "Breddpunkt" +"vector.flatten" = "Platta till" +"vector.join" = "Sammanfoga markering" +"vector.offset" = "Förskjut bana" +"vector.outlineStroke" = "Konvertera kontur till form" +"vector.reverse" = "Vänd banans riktning" +"vector.simplify" = "Förenkla vektor" +"vector.smoothJoin" = "Sammanfoga markering mjukt" +"version.autosave" = "Autospara version" +"version.delete" = "Radera version" +"version.duplicate" = "Duplicera version" +"version.exitPreview" = "Tillbaka till aktuell version" +"version.list" = "Lista versioner" +"version.preview" = "Visa version" +"version.rename" = "Byt namn på version" +"version.restore" = "Återställ version" +"view.assets" = "Panelen Tillgångar" +"view.comments" = "Kommentarer" +"view.contextMenu" = "Visa snabbmeny" +"view.design" = "Panelen Design" +"view.designPrototype" = "Växla mellan Design och Prototyp" +"view.devMode" = "Utvecklarläge" +"view.frameOutlines" = "Konturlinjer för ramar" +"view.fullscreen" = "Helskärm" +"view.get" = "Hämta vy" +"view.layers" = "Panelen Lager" +"view.layoutGrids" = "Layoutrutnät" +"view.leftSidebar" = "Visa/dölj vänster sidofält" +"view.maskOutlines" = "Konturlinjer för masker" +"view.multiplayerCursors" = "Samarbetsmarkörer" +"view.nextFrame" = "Zooma till nästa ram" +"view.nextPage" = "Nästa sida" +"view.nextRegion" = "Fokusera nästa region" +"view.outlines" = "Konturlinjer" +"view.outlinesHiddenLayers" = "Inkludera dolda lager i konturlinjer" +"view.outlinesObjectBounds" = "Inkludera objektgränser i konturlinjer" +"view.outlinesSelection" = "Konturlinjer för markering" +"view.pan" = "Panorera vyn" +"view.panelWidths" = "Återställ panelbredder" +"view.pixelGrid" = "Pixelrutnät" +"view.pixelPreview" = "Pixelförhandsgranskning" +"view.prevFrame" = "Zooma till föregående ram" +"view.prevPage" = "Föregående sida" +"view.previousRegion" = "Fokusera föregående region" +"view.proofSrgb" = "Skärmkorrektur i sRGB" +"view.prototype" = "Panelen Prototyp" +"view.rulers" = "Linjaler" +"view.set" = "Ange vy" +"view.slices" = "Skivor" +"view.snapGeometry" = "Fäst mot geometri" +"view.snapObjects" = "Fäst mot objekt" +"view.snapPixel" = "Fäst mot pixelrutnät" +"view.themeDark" = "Mörkt tema" +"view.themeLight" = "Ljust tema" +"view.themeSystem" = "Följ systemets tema" +"view.toggleUi" = "Visa/dölj gränssnitt" +"view.zoom100" = "Zooma till 100 %" +"view.zoom200" = "Zooma till 200 %" +"view.zoom50" = "Zooma till 50 %" +"view.zoomAt" = "Zoomverktyg" +"view.zoomFit" = "Zooma för att anpassa" +"view.zoomIn" = "Zooma in" +"view.zoomOut" = "Zooma ut" +"view.zoomSelection" = "Zooma till markering" +"widget.click" = "Klicka på widget" +"widget.insert" = "Infoga widget" +"widget.list" = "Lista widgetar" +"window.focus" = "Placera fönstret längst fram" +"window.new" = "Nytt fönster" [menu] -"bool.exclude" = "bool.exclude" -"bool.flatten" = "bool.flatten" -"bool.intersect" = "bool.intersect" -"bool.subtract" = "bool.subtract" -"bool.union" = "bool.union" -"file.duplicate" = "file.duplicate" -"file.import" = "file.import" -"file.rename" = "file.rename" -"libraries.open" = "libraries.open" -"select.sameEffect" = "select.sameEffect" -"select.sameFill" = "select.sameFill" -"select.sameFont" = "select.sameFont" -"select.sameInstance" = "select.sameInstance" -"select.sameProperties" = "select.sameProperties" -"select.sameStroke" = "select.sameStroke" -"select.sameText" = "select.sameText" -"text.alignCenter" = "text.alignCenter" -"text.alignJustify" = "text.alignJustify" -"text.alignLeft" = "text.alignLeft" -"text.alignRight" = "text.alignRight" -"view.assets" = "view.assets" -"view.design" = "view.design" -"view.layers" = "view.layers" -"view.outlines" = "view.outlines" -"view.outlinesHiddenLayers" = "view.outlinesHiddenLayers" -"view.outlinesObjectBounds" = "view.outlinesObjectBounds" -"view.outlinesSelection" = "view.outlinesSelection" -"view.prototype" = "view.prototype" -"view.themeDark" = "view.themeDark" -"view.themeLight" = "view.themeLight" -"view.themeSystem" = "view.themeSystem" +"bool.exclude" = "Uteslut" +"bool.flatten" = "Platta till" +"bool.intersect" = "Skärning" +"bool.subtract" = "Subtrahera" +"bool.union" = "Förena" +"file.duplicate" = "Duplicera" +"file.import" = "Importera från fil…" +"file.rename" = "Byt namn" +"libraries.open" = "Öppna bibliotek" +"select.sameEffect" = "Effekt" +"select.sameFill" = "Fyllning" +"select.sameFont" = "Teckensnitt" +"select.sameInstance" = "Instans" +"select.sameProperties" = "Egenskaper" +"select.sameStroke" = "Kontur" +"select.sameText" = "Textegenskaper" +"text.alignCenter" = "Centrera text" +"text.alignJustify" = "Marginaljustera text" +"text.alignLeft" = "Vänsterjustera text" +"text.alignRight" = "Högerjustera text" +"view.assets" = "Tillgångar" +"view.design" = "Design" +"view.layers" = "Lager" +"view.outlines" = "Visa konturlinjer" +"view.outlinesHiddenLayers" = "Inkludera dolda lager" +"view.outlinesObjectBounds" = "Inkludera objektgränser" +"view.outlinesSelection" = "Visa konturlinjer för markering" +"view.prototype" = "Prototyp" +"view.themeDark" = "Mörk" +"view.themeLight" = "Ljus" +"view.themeSystem" = "Följ systemet" From cc26c29217b125cb7222e91cd2fafd713513cfa8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 23:57:45 -0400 Subject: [PATCH 05/28] ci: switch to shared nix workflow from craft-common-actions --- .github/workflows/nix.yml | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 18392587..01cb9cd6 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -18,16 +18,6 @@ concurrency: cancel-in-progress: true jobs: - eval: - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 - - name: nix flake check --no-build - run: | - if [ -f flake.nix ]; then - nix flake check --no-build --all-systems - else - echo "No flake.nix present in this repository; skipping check." - fi + nix: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/nix.yml@main From 167e06f6950033eb4c3d072624818de077ef9521 Mon Sep 17 00:00:00 2001 From: Zaxaerith Date: Fri, 16 Oct 2026 12:08:26 +0800 Subject: [PATCH 06/28] docs: correct example plugin count (#392) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0910bf9e..a072a06f 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. - **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). -- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). +- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. From 107aa27d0a7407122b598df79f57af75d1ba00e3 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Fri, 16 Oct 2026 06:20:40 +0200 Subject: [PATCH 07/28] docs: Swedish now covers the command labels and menu wordings ROADMAP.md, docs/localization-parity.md and docs/translating.md said Swedish had 120 of the 588 command labels and menu wordings; with the previous commit it has all of them. Worded without counts, so the docs stay right as commands are added. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- docs/localization-parity.md | 4 ++-- docs/translating.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..be9e9810 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Also shipped: Swedish (`sv`: every UI string, command label and menu wording; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 45a7a925..d3f69a9a 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -100,8 +100,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: Swedish (`sv`): every UI string translated, and 120 of the 588 command labels and -menu wordings (the rest show in English); native review welcome. Figma ships none beyond the eight +Other languages: Swedish (`sv`): every UI string, command label and menu wording translated; native +review welcome. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries diff --git a/docs/translating.md b/docs/translating.md index d93c0255..3aa42d65 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -22,7 +22,7 @@ keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » qu was machine-drafted and reviewed by a native speaker. `es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`) and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review, and -Swedish (`sv`), which translates every UI string and some command labels. +Swedish (`sv`), which translates every UI string, command label and menu wording. ## The catalogs From a911ab9614c3bc01c19ad86035bd8872a7ffc9cf Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 08:29:55 +0000 Subject: [PATCH 08/28] Reject negative or fractional index in fill.set and stroke.set Found while testing crates/engine/src/cmd/edit.rs:1180 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/lib.rs | 2 ++ crates/engine/src/paint_index_tests.rs | 39 ++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/paint_index_tests.rs diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b93..35930cb7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1177,7 +1177,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; - let idx = num(p, "index").map(|x| x.max(0.0) as usize); + let idx = if get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; let new_paint = match get(p, "paint") { Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), None => None, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..a84ca21b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1594,6 +1594,8 @@ mod page_copy_navigation_tests; #[cfg(test)] mod page_target_tests; #[cfg(test)] +mod paint_index_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; diff --git a/crates/engine/src/paint_index_tests.rs b/crates/engine/src/paint_index_tests.rs new file mode 100644 index 00000000..eddea306 --- /dev/null +++ b/crates/engine/src/paint_index_tests.rs @@ -0,0 +1,39 @@ +//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of +//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`). + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn paints(s: &Session, id: NodeId) -> String { + let n = s.doc().unwrap().node(id).unwrap(); + format!("{:?} {:?}", n.fills, n.strokes) +} + +#[test] +fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() { + let mut s = Session::new(); + let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + for add in ["fill.add", "stroke.add"] { + for c in ["#0000FF", "#FFFF00"] { + ex(&mut s, add, json!({"ids": [r], "color": c})); + } + } + let before = paints(&s, r); + for cmd in ["fill.set", "stroke.set"] { + for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] { + let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"})); + assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}"); + assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document"); + } + ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"})); + assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(paints(&s, r), before); + } +} From c5a8420089de30cc222786efae09db9423e44967 Mon Sep 17 00:00:00 2001 From: Hamza Date: Fri, 16 Oct 2026 16:27:05 +0500 Subject: [PATCH 09/28] Make the control server's connection-limit test pass on macOS `cargo xtask ci` was red on a clean main on macOS: the test's last shutdown runs after the server has answered an invalid request and closed the connection, and `shutdown(2)` on a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux returns success. The test is right about what it checks; it checked a return value that is not portable. Closing the client is what the worker thread waits for, and dropping the socket does that whether or not shutdown reported anything. The file's other six shutdowns run while the peer is still open, which is well defined on both platforms, so they stay checked. With no CI running the tests on pull requests, the local gate is the only signal a contributor gets, and it could not be green on macOS. --- apps/figcraft/src/control_server_tests.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index d243fb91..008170fb 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -160,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { assert_eq!(workers.len(), 1); client.write_all(b"invalid\n").unwrap(); assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false); - client.shutdown(Shutdown::Both).unwrap(); + // The server closes this connection after answering an invalid request, and shutting down + // a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux + // returns Ok. Closing the client is what the worker is waiting for, so the result is moot: + // the other shutdowns in this file run while the peer is still open and stay checked. + let _ = client.shutdown(Shutdown::Both); workers.pop().unwrap().join().unwrap(); } From 0f4c79b629b23d6a2bc792387c83d40d1dc639a4 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:33:59 +0800 Subject: [PATCH 10/28] M0.9: a script runs as one undo step and resolves `$step`, like command.batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `figcraft-cli run` ran each step of a script on its own, so a script's `{"$step": n}` reached `parent` unresolved and the layer landed on the page while every step reported success — the same commands through `command.batch` (the MCP `batch` tool, the control channel, plugins) put it inside the frame. One `edit.undo` took back one step of a script rather than the script, and a failing step left the earlier ones applied. `Session::batch_step` is now the one path both take — one step of a batch, its `$step` references resolved against the results before it — and `run_script` wraps its steps in `Session::grouped`: the whole script is one undo step, and a step that fails takes the script back. The result lines come out once the script is through, so none of them reports work a later failure took back; a failure prints one line with `rolledBack: true` and exits 1. `docs/cli.md` documents the reference and the output. --- apps/figcraft-cli/src/files.rs | 92 ++++++++++++++++++++++++++++++---- crates/engine/src/lib.rs | 13 ++++- docs/cli.md | 24 ++++++++- 3 files changed, 116 insertions(+), 13 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 4fb485e1..46c8dd22 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -333,17 +333,31 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe } None => Session::new(), }; - for (i, (cmd, params)) in steps.iter().enumerate() { - let r = s.execute(cmd, params); - s.ui_requests.clear(); - match r { - Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})), - Err(e) => { - println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()})); - eprintln!("error: step {i} ({cmd}) failed: {e}"); - return Ok(ExitCode::from(1)); - } + // One undo step and all-or-nothing, the way the same commands behave through `command.batch` + // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and + // a step that fails takes the whole script back instead of leaving the earlier steps applied. + // The result lines are held back until the script is through, so none of them reports work that + // was rolled back. + let mut results: Vec = Vec::with_capacity(steps.len()); + let mut failed_at = 0; + let outcome = s.grouped(&format!("run {script}"), |s| { + for (i, (cmd, params)) in steps.iter().enumerate() { + failed_at = i; + let v = s.batch_step(&results, i, cmd, params)?; + s.ui_requests.clear(); + results.push(v); } + Ok(()) + }); + if let Err(e) = outcome { + let (i, (cmd, _)) = (failed_at, &steps[failed_at]); + println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); + eprintln!("error: step {i} ({cmd}) failed: {e}"); + eprintln!("the script's document changes were rolled back"); + return Ok(ExitCode::from(1)); + } + for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { + println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -422,4 +436,62 @@ mod tests { assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); std::fs::remove_dir_all(dir).unwrap(); } + + fn temp_dir(tag: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id())); + std::fs::create_dir(&dir).unwrap(); + dir + } + + /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step + /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it + /// as "no parent" and the layer landed on the page — with every step reporting success. + #[test] + fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() { + let dir = temp_dir("run-step"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + let top = doc.node(page).unwrap().children.clone(); + assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too"); + assert_eq!(doc.node(top[0]).unwrap().name, "Card"); + let inner = doc.node(top[0]).unwrap().children.clone(); + assert_eq!(inner.len(), 1, "the rectangle went inside the frame"); + assert_eq!(doc.node(inner[0]).unwrap().name, "child"); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// A step that fails takes the whole script back: nothing is written, and the failure says so. + #[test] + fn a_failed_step_leaves_no_output() { + let dir = temp_dir("run-rollback"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + // The failure is the script's, not the call's: the CLI reports it and exits non-zero. + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + assert!(!out.exists(), "a script that failed writes nothing"); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..27212f96 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1135,13 +1135,22 @@ impl Session { self.grouped(label, |s| { let mut results: Vec = Vec::with_capacity(cmds.len()); for (step, (id, p)) in cmds.iter().enumerate() { - let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? }; - results.push(s.execute(id, &p)?); + let v = s.batch_step(&results, step, id, p)?; + results.push(v); } Ok(results) }) } + /// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of + /// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one + /// undo step; a caller with its own list — the CLI's script runner — uses this so its steps + /// resolve `$step` the same way, inside its own [`Session::grouped`]. + pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result { + let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? }; + self.execute(cmd, &p) + } + /// Run `f` as one undo step called `label`: the commands it executes push no steps of their /// own, and when it fails the documents stay at their group-start state. pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { diff --git a/docs/cli.md b/docs/cli.md index 9f5df40e..be54add2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -103,7 +103,29 @@ Runs commands in a headless session (a new document, or `--in`), printing one JS {"command":"frame.create","ok":true,"result":{"id":2},"step":0} ``` -A failing step prints `"ok": false` with the error and exits with code 1 (nothing is saved). +The whole script is **one undo step**, and it is **all-or-nothing**, exactly like running the same +commands through `command.batch` (the MCP tool `batch`, the control channel, plugins): `{"$step": n}` +in a step's params is the `id` in an earlier step's result, so a step can target a layer an earlier +step of the same script created: + +```json +[ + {"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 200, "height": 100}}, + {"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Hi"}} +] +``` + +The result lines are printed once the script is through, so none of them reports work a later step +took back. + +A failing step prints one line with `"ok": false`, the error and `"rolledBack": true`, writes +nothing (`--out` is not saved, and the session's documents go back to how the script found them), +and exits with code 1: + +```text +{"step":1,"command":"node.set","ok":false,"error":"no such node 99999","rolledBack":true} +``` + `--out` saves the result by extension like `convert`. Script formats: a JSON array of `{"command": id, "params": {...}}`, an object From 9daf39e24ce55e369bdb3e1949927f72c18afd91 Mon Sep 17 00:00:00 2001 From: phonedu <135960865+phonedu@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:53:43 +0800 Subject: [PATCH 11/28] M1: a param that names a layer says so when it isn't one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `node.create` read its `parent` as `id(p, "parent").unwrap_or(page)`, so a `parent` that isn't a layer id — a `{"$step": n}` nothing had resolved, a name, a negative number — quietly meant "no parent" and the layer landed on the page, with the call reporting success. The same shape sat on `image.place` and `instance.create`, on `index` (a string index appended where it was asked to insert), and on the `ids` of every command that targets layers (an entry that wasn't an id was dropped and the rest ran). A number that names nothing was reported; anything that isn't a number at all was not. `params::opt_id` and `params::opt_index` now say what the commands mean: missing and `null` are "none", and anything else has to be a layer id (or a whole, non-negative number) and is named when it isn't. `req_targets` refuses an `ids` entry that isn't an id, before falling back to the selection. A number that names no layer still reports `no such node`, and a boolean is still a number to `f64_of`, as it is for `x`, `width` and the rest. This turns a silently wrong result into an error on purpose, so a caller that passed something that isn't an id and relied on the fallback now hears about it. --- crates/engine/src/cmd/component.rs | 2 +- crates/engine/src/cmd/create.rs | 6 +- .../engine/src/id_params_behaviour_tests.rs | 151 ++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/params.rs | 38 ++++- 5 files changed, 194 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/id_params_behaviour_tests.rs diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 0fb64816..e35c3f5b 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { let cmd = "instance.create"; let c = req_id(cmd, p, "component")?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc_mut()?; let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; let (x, y) = match (num(p, "x"), num(p, "y")) { diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 2150701a..9b471290 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?; let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let doc = s.doc()?; let pn = doc.get(parent)?; if !pn.kind.is_container() { @@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { node.width = tw; node.height = th; } - let index = num(p, "index").map(|i| i.max(0.0) as usize); + let index = crate::params::opt_index(cmd, p, "index")?; doc.insert(node, parent, index)?; if let Some(Value::Object(props)) = get(p, "props") { super::props::apply(cmd, doc, nid, props)?; @@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result { _ => ImageScaleMode::Fill, }; let page = s.page()?; - let parent = id(p, "parent").unwrap_or(page); + let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page); let ds = s.ds_mut()?; let doc = ds.doc_mut(); let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?; diff --git a/crates/engine/src/id_params_behaviour_tests.rs b/crates/engine/src/id_params_behaviour_tests.rs new file mode 100644 index 00000000..ae77c8a1 --- /dev/null +++ b/crates/engine/src/id_params_behaviour_tests.rs @@ -0,0 +1,151 @@ +//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than +//! quietly meaning "none". +//! +//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id — +//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the +//! layer landed on the page while the call reported success. The same shape sat on `image.place` +//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets +//! layers (an entry that wasn't an id was dropped and the rest ran). + +use crate::params::id_of; +use crate::{EngineError, Session}; +use figcraft_doc::*; +use serde_json::{Value, json}; + +/// The page's children, by name (bottom first). +fn top(s: &Session) -> Vec { + let doc = s.doc().unwrap(); + let page = s.page().unwrap(); + doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect() +} + +/// The message of a failure, or `None` when the call went through. +fn failed(r: std::result::Result) -> Option { + match r { + Ok(_) => None, + Err(e) => Some(e.to_string()), + } +} + +fn rect(parent: Value) -> Value { + json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10}) +} + +fn card(s: &mut Session) -> NodeId { + let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap(); + id_of(&v["id"]).unwrap() +} + +#[test] +fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] { + let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused")); + assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}"); + } + assert_eq!(top(&s), ["Card"], "nothing landed on the page"); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame"); +} + +#[test] +fn leaving_the_parent_out_or_null_still_means_the_page() { + let mut s = Session::new(); + let card = card(&mut s); + for parent in [None, Some(json!(null))] { + let mut p = rect(json!(0)); + match parent { + None => _ = p.as_object_mut().unwrap().remove("parent"), + Some(v) => p["parent"] = v, + } + s.execute("node.create", &p).unwrap(); + } + assert_eq!(top(&s), ["Card", "child", "child"]); + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty()); +} + +#[test] +fn a_parent_id_that_names_nothing_is_still_reported() { + let mut s = Session::new(); + card(&mut s); + let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail")); + assert!(msg.contains("no such node 99999"), "{msg}"); +} + +#[test] +fn an_index_that_is_not_a_whole_non_negative_number_is_refused() { + let mut s = Session::new(); + let card = card(&mut s); + // (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every + // other number, so it is deliberately not in this list.) + for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] { + let mut p = rect(json!(card)); + p["index"] = index.clone(); + let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused")); + assert!(msg.contains("`index`"), "{index}: {msg}"); + } + let doc = s.doc().unwrap(); + assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted"); +} + +#[test] +fn a_whole_index_still_inserts_where_it_says() { + let mut s = Session::new(); + let card = card(&mut s); + for name in ["a", "b"] { + s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + } + s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10})) + .unwrap(); + let doc = s.doc().unwrap(); + let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect(); + assert_eq!(names, ["a", "mid", "b"]); +} + +#[test] +fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() { + let mut s = Session::new(); + let card = card(&mut s); + // The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame. + for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] { + let msg = + failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused")); + assert!(msg.contains("`ids`"), "{ids}: {msg}"); + } + let doc = s.doc().unwrap(); + assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed"); +} + +#[test] +fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() { + let mut s = Session::new(); + let card = card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail")); + assert!(msg.contains("`ids` must be an array"), "{msg}"); + // An explicit null says the same as leaving it out: the selection. + s.execute("select.set", &json!({"ids": [card]})).unwrap(); + s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap(); + assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected"); +} + +#[test] +fn a_single_id_that_is_not_an_id_is_refused() { + let mut s = Session::new(); + card(&mut s); + let msg = + failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail")); + assert!(msg.contains("`id` must be a node id"), "{msg}"); +} + +#[test] +fn an_image_and_an_instance_parent_are_checked_the_same_way() { + let mut s = Session::new(); + card(&mut s); + let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}}))); + assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place"); + let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}}))); + assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 34653bd5..fac5f477 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1548,6 +1548,8 @@ mod history_behaviour_tests; #[cfg(test)] mod html_codegen_tests; #[cfg(test)] +mod id_params_behaviour_tests; +#[cfg(test)] mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index bbaa42a4..67a5b80a 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -88,6 +88,25 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id"))) } +/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a +/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" — +/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't +/// resolved yet silently becomes "no parent" and the layer lands on the page. +pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {v})"))), + } +} + +/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none". +pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(_) => req_index(cmd, p, key).map(Some), + } +} + /// `ids` (array), `id` (single), or the current selection. pub fn targets(s: &Session, p: &Value) -> Vec { if let Some(Value::Array(a)) = get(p, "ids") { @@ -99,8 +118,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec { s.selection() } -/// Like [`targets`] but an empty result is an error. +/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an +/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit +/// `null` says the same as leaving the key out. pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result> { + match get(p, "ids") { + Some(Value::Array(a)) => { + if let Some(v) = a.iter().find(|v| id_of(v).is_none()) { + return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {v})"))); + } + } + Some(Value::Null) | None => { + if let Some(v) = get(p, "id").filter(|v| !v.is_null()) + && id_of(v).is_none() + { + return Err(bad(cmd, format!("`id` must be a node id (got {v})"))); + } + } + Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {v})"))), + } let t = targets(s, p); if t.is_empty() { return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into())); From 0e25cf4c2651db9395024cff75b7e1c5958bc7b8 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:47:07 +0000 Subject: [PATCH 12/28] Refuse pages in edit.copy and edit.cut Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++++-- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2c767b93..ab503a02 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -499,7 +499,12 @@ fn bump_name(n: &str) -> String { pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; let doc = &ds.doc; - let ids = z_sorted(doc, &roots(doc, ids)); + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled("edit.copy".into(), "pages can't be copied; use page.duplicate".into())); + } + let ids = z_sorted(doc, &roots(doc, &layers)); let mut nodes = Vec::new(); let mut images = BTreeMap::new(); for i in &ids { @@ -542,7 +547,9 @@ fn copy(s: &mut Session, p: &Value) -> Result { fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; copy_to_clipboard(s, &ids)?; - delete(s, &json!({"ids": ids})) + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) } /// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dbf432d4..6010559e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,3 +621,20 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = sess(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let r = s.execute(cmd, &json!({"ids": [page.0]})); + if let Ok(v) = &r { + assert!(v.get("deleted").is_none_or(|d| d == &json!([])), "{cmd} reported the page as deleted: {v}"); + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} From 554821afef53d20d7894eeddd9b48f7a265a9eb0 Mon Sep 17 00:00:00 2001 From: Barzabel Date: Fri, 16 Oct 2026 17:31:11 +0300 Subject: [PATCH 13/28] M9: a damaged generation field no longer drops a log's flushed edits (#408) A log whose generation isn't its snapshot's was skipped as a leftover of a compaction cut short, but neither generation is under a checksum: one flipped bit made the server serve the older document, report nothing and overwrite the log. Replay such a log over the snapshot instead: replay skips what the snapshot already holds, so a genuine leftover changes nothing, while edits the snapshot lacks are recovered and the damage is reported. --- apps/figcraft-server/src/disk.rs | 6 +- apps/figcraft-server/src/store.rs | 30 +++- apps/figcraft-server/tests/header_recovery.rs | 139 ++++++++++++++++++ docs/server.md | 5 +- 4 files changed, 173 insertions(+), 7 deletions(-) create mode 100644 apps/figcraft-server/tests/header_recovery.rs diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index f58f25b2..af6e0f99 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -8,8 +8,10 @@ //! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding. //! //! Compaction writes the next generation's snapshot, then an empty log of that generation. A log -//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it -//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short). +//! whose generation isn't the snapshot's is usually from before a compaction that was cut short +//! (what it holds is in the snapshot already), but no checksum covers either generation, so the +//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its +//! first damaged record (a write cut short). use std::fs::{self, File}; use std::io::{self, Read, Write}; diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 538adeff..ea9bc626 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -440,7 +440,7 @@ impl Store { && let Ok(log) = disk::read_log(&bytes) && log.generation != generation { - log::info!("file at {}: its log predates the last compaction; it is ignored", path.display()); + log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display()); } let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum(); Ok(Entry { meta, links, log: None, bytes, forced: 0 }) @@ -1126,8 +1126,32 @@ impl Store { fresh_log = true; } } - // From before a compaction that was cut short: the snapshot has it all. - Ok(_) => {} + // From before a compaction that was cut short, the snapshot has it all; or a generation + // field damaged, which no checksum covers. Replaying tells them apart without trusting + // it: entries the snapshot holds are skipped, so only edits it lacks change anything. + Ok(log) => { + let before = server.seq(); + let mut stopped = None; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + stopped = Some(e); + break; + } + } + let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); + if recovered > 0 { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ + {recovered} changes the snapshot didn't; they were recovered" + )); + } + if let Some(e) = stopped { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ + making sense after {recovered} newer changes ({e}); the rest was dropped" + )); + } + } Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), } for i in issues { diff --git a/apps/figcraft-server/tests/header_recovery.rs b/apps/figcraft-server/tests/header_recovery.rs new file mode 100644 index 00000000..d728908d --- /dev/null +++ b/apps/figcraft-server/tests/header_recovery.rs @@ -0,0 +1,139 @@ +//! A generation field damaged after an abrupt stop must not make the server drop a log that still +//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the +//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short +//! still restores silently. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use figcraft_collab::message::{Access, ClientMsg}; +use figcraft_collab::{Client, Outgoing, Replica, Server}; +use figcraft_collab_net::hub::Backend; +use figcraft_doc::Document; +use figcraft_server::config::Config; +use figcraft_server::disk::{self, LOG, SNAPSHOT}; +use figcraft_server::store::{FILES, Store}; +use std::fs; +use std::path::{Path, PathBuf}; + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap())); + fs::create_dir(&path).unwrap(); + Self(path) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn page_name(server: &Server) -> String { + let doc = server.document(); + doc.node(doc.first_page().unwrap()).unwrap().name.clone() +} + +/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then +/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state. +fn renamed_and_stopped(data: &Path) -> (String, Server) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap(); + let (info, _, _) = store.publish("Fixture", &snapshot).unwrap(); + let id = info.id; + let mut server = store.open(&id).unwrap(); + let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") }; + let (_, messages) = server.join_as(hello, Access::Edit).unwrap(); + let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") }; + let mut client = Client::join(welcome).unwrap(); + store.changed(&id, &mut server); + let mut edited = client.document().clone(); + let page = edited.first_page().unwrap(); + edited.node_mut(page).unwrap().name = "Kept".into(); + let change = client.edit(&edited).unwrap().unwrap(); + assert!(!server.receive(client.id(), change).is_empty()); + store.changed(&id, &mut server); + assert!(store.flush().is_empty(), "the edit must reach disk"); + assert_eq!(page_name(&server), "Kept"); + let dir = data.join(FILES).join(&id); + assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty()); + assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log"); + drop(store); + (id, server) +} + +/// Reopen the data directory: the file's first page name and the problems reported. +fn reopen(data: &Path, id: &str) -> (String, Vec) { + let mut store = Store::open(data, Config::default().store()).unwrap(); + let server = store.open(id).unwrap(); + (page_name(&server), store.problems().to_vec()) +} + +fn flip(path: &Path, at: usize) { + let mut bytes = fs::read(path).unwrap(); + bytes[at] ^= 1; + fs::write(path, bytes).unwrap(); +} + +/// The generation field's first byte in both headers (after the magic and the format byte). +const GENERATION_AT: usize = 5; + +#[test] +fn an_intact_log_restores_its_edits_without_a_report() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "{problems:?}"); +} + +#[test] +fn a_damaged_log_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let log = fixture.0.join(FILES).join(&id).join(LOG); + flip(&log, GENERATION_AT); + // The records themselves still read: only the header's generation changed. + let read = disk::read_log(&fs::read(&log).unwrap()).unwrap(); + assert!(read.damaged.is_none() && !read.entries.is_empty()); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + // And it stays: the recovered edit is now in the file's snapshot. + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT); + flip(&snapshot, GENERATION_AT); + assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum"); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept", "the flushed rename must survive"); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}"); + assert_eq!(reopen(&fixture.0, &id).0, "Kept"); +} + +#[test] +fn a_damaged_log_format_is_still_reported() { + let fixture = Fixture::new(); + let (id, _) = renamed_and_stopped(&fixture.0); + flip(&fixture.0.join(FILES).join(&id).join(LOG), 4); + let (_, problems) = reopen(&fixture.0, &id); + assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}"); +} + +#[test] +fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() { + let fixture = Fixture::new(); + let (id, server) = renamed_and_stopped(&fixture.0); + // The next snapshot was written (it holds the rename); the old log wasn't replaced yet. + let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap(); + fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap(); + let (name, problems) = reopen(&fixture.0, &id); + assert_eq!(name, "Kept"); + assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}"); +} diff --git a/docs/server.md b/docs/server.md index f2e4820c..021d0e3f 100644 --- a/docs/server.md +++ b/docs/server.md @@ -337,8 +337,9 @@ the address it came from and when it was last opened (for expiry). before the server tells anyone about the change. - **Compaction** writes the file's state as the next snapshot and starts an empty log, after `compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server - stops. A log left over from a compaction cut short is recognised and ignored (the snapshot holds - it all). + stops. A log left over from a compaction cut short is replayed over the snapshot, which already + holds it all, so nothing changes. If a damaged generation field made a current log look like such + a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported. - **Damage is skipped and reported, never deleted.** At startup a folder whose description, links or snapshot can't be read is left out and logged; a log that ends in a cut-short or damaged record loses only that end (logged when the file opens). Leftover temporary files are removed. From 8c90ef962adb902a72c8127c9b0f63bd2df315bd Mon Sep 17 00:00:00 2001 From: Yarila Date: Fri, 16 Oct 2026 23:36:23 +0500 Subject: [PATCH 14/28] M7: open inputs for parameterized menu actions --- ROADMAP.md | 3 +- crates/ui-egui/locales/en.toml | 3 +- crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/src/dialogs.rs | 92 ++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menu_input_tests.rs | 74 +++++++++++++++++++++ crates/ui-egui/src/menus.rs | 29 ++++++++ docs/gaps.md | 6 +- 9 files changed, 205 insertions(+), 9 deletions(-) create mode 100644 crates/ui-egui/src/menu_input_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index c3c12085..c79f0ea7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (menu and Actions parameter dialogs; bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Affected menu and Actions commands that need values now open the relevant input or panel instead of showing a parameter error; Preferences offers language in a dropdown. Headline numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3c127fc6..5d978897 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -829,6 +829,7 @@ name = "English" "No model credits were built into this copy." = "No model credits were built into this copy." "No number variables fit here." = "No number variables fit here." "No number variables yet. Create them in Local variables." = "No number variables yet. Create them in Local variables." +"No other pages" = "No other pages" "No paints" = "No paints" "No plugins on {server}." = "No plugins on {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "No plugins yet. Import a development plugin from its figcraft-plugin.json." @@ -1603,4 +1604,4 @@ name = "English" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" -"⌥ drag" = "⌥ drag" \ No newline at end of file +"⌥ drag" = "⌥ drag" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 901283f1..c0329964 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -830,6 +830,7 @@ status = "draft — needs native review" "No model credits were built into this copy." = "Esta copia no incluye créditos de modelos." "No number variables fit here." = "Ninguna variable numérica encaja aquí." "No number variables yet. Create them in Local variables." = "Aún no hay variables numéricas. Créalas en Variables locales." +"No other pages" = "No hay otras páginas" "No paints" = "Sin pinturas" "No plugins on {server}." = "No hay plugins en {server}." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aún no hay plugins. Importa un plugin de desarrollo desde su figcraft-plugin.json." diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 3ab40757..4228f8bc 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -819,6 +819,7 @@ name = "Русский" "No model credits were built into this copy." = "В этой сборке нет сведений об использованных моделях." "No number variables fit here." = "Нет подходящих числовых переменных." "No number variables yet. Create them in Local variables." = "Числовых переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No other pages" = "Других страниц нет" "No paints" = "Заливок нет" "No plugins on {server}." = "На {server} нет плагинов." "No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Плагинов пока нет. Импортируйте плагин для разработки из его figcraft-plugin.json." diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6e0ee3a6..31eb1adb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -71,6 +71,15 @@ pub enum AboutTab { Models, } +/// Values collected by a menu or Actions command before dispatching it to the engine. +#[derive(Clone, Debug, PartialEq)] +pub enum MenuInput { + RenameFile(String), + Link(String), + Offset(f64), + MoveToPage, +} + impl AboutTab { pub const ALL: [AboutTab; 3] = [AboutTab::About, AboutTab::Contributors, AboutTab::Models]; @@ -137,6 +146,7 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) { pub fn keys(app: &mut FigApp, ctx: &Context) { let u = &mut app.ui; let open = u.color_profile.is_some() + || u.menu_input.is_some() || u.find.is_some() || u.batch_rename.is_some() || u.confirm_close @@ -147,7 +157,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { if !open || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { return; } - if u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() { + if u.menu_input.take().is_some() || u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() { return; } for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] { @@ -265,12 +275,17 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.label(tr("Language")); // Each language by its own name; System follows the OS (the browser on the web). let langs = i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name)); - for (code, name) in [("system", tr("System"))].into_iter().chain(langs) { - if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { - app.run("pref.language", json!({"language": code})); - ui.ctx().request_repaint(); + let choices: Vec<_> = [("system", tr("System"))].into_iter().chain(langs).collect(); + let selected = + choices.iter().find(|(code, _)| app.session.prefs.language.eq_ignore_ascii_case(code)).map_or(tr("System"), |(_, name)| *name); + egui::ComboBox::from_id_salt("pref_language").selected_text(selected).width(220.0).show_ui(ui, |ui| { + for (code, name) in choices { + if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { + app.run("pref.language", json!({"language": code})); + ui.ctx().request_repaint(); + } } - } + }); }); if let Some(lang) = i18n::current().filter(|l| l.draft) { let note = trf("{language}: draft translation, waiting for a native speaker's review.", &[("language", &lang.name)]); @@ -321,6 +336,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.ui.find = if open { Some((find, replace)) } else { None }; } batch_rename_dialog(app, ctx); + menu_input_dialog(app, ctx); crate::variables_panel::show(app, ctx); crate::dev_compare::show(app, ctx); crate::branch_ui::show(app, ctx); @@ -335,6 +351,70 @@ pub fn show(app: &mut FigApp, ctx: &Context) { crate::windows::confirm_close(app, ctx); } +/// Menus and Actions cannot pass a value themselves. Gather it here, then run the regular +/// command so the same validation and undo behaviour apply as for programmatic callers. +fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { + let Some(mut input) = app.ui.menu_input.take() else { return }; + let title = match &input { + MenuInput::RenameFile(_) => i18n::command("file.rename", "Rename file"), + MenuInput::Link(_) => i18n::command("text.link", "Create link"), + MenuInput::Offset(_) => i18n::command("vector.offset", "Offset path"), + MenuInput::MoveToPage => i18n::command("object.moveToPage", "Move to page"), + }; + let mut open = true; + let mut action: Option<(&str, serde_json::Value)> = None; + window(&title, ctx).open(&mut open).show(ctx, |ui| { + ui.set_width(320.0); + match &mut input { + MenuInput::RenameFile(name) => { + let field = ui.add(egui::TextEdit::singleline(name).desired_width(300.0)); + if (ui.button(tr("Rename")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + && !name.trim().is_empty() + { + action = Some(("file.rename", json!({"name": name.trim()}))); + } + } + MenuInput::Link(url) => { + let field = ui.add(egui::TextEdit::singleline(url).hint_text("https://").desired_width(300.0)); + if (ui.button(tr("Done")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) && !url.trim().is_empty() + { + action = Some(("text.link", json!({"url": url.trim()}))); + } + } + MenuInput::Offset(offset) => { + ui.add(egui::DragValue::new(offset).speed(1.0).range(-100_000.0..=100_000.0)); + if ui.button(tr("Offset")).clicked() { + action = Some(("vector.offset", json!({"offset": offset}))); + } + } + MenuInput::MoveToPage => { + let mut choices = 0; + if let Ok(ds) = app.session.ds() { + for &page in &ds.doc.pages { + if page == ds.page || ds.doc.is_divider(page) { + continue; + } + if let Some(node) = ds.doc.node(page) + && ui.button(&node.name).clicked() + { + action = Some(("object.moveToPage", json!({"page": page}))); + } + choices += 1; + } + } + if choices == 0 { + ui.label(tr("No other pages")); + } + } + } + }); + if let Some((id, params)) = action { + app.run(id, params); + } else if open { + app.ui.menu_input = Some(input); + } +} + /// The Rename layers dialog's fields. #[derive(Clone, Debug, Default, PartialEq)] pub struct BatchRenameUi { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 20a8a77c..c4671391 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -132,6 +132,8 @@ mod live_tests; #[cfg(test)] mod live_text_tests; #[cfg(test)] +mod menu_input_tests; +#[cfg(test)] mod online_tests; #[cfg(test)] mod open_issues_tests; @@ -410,6 +412,8 @@ pub struct UiState { /// The tab the About window shows. pub about_tab: dialogs::AboutTab, pub preferences: bool, + /// A menu command that needs a value before the engine can run it. + pub menu_input: Option, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -537,6 +541,7 @@ impl Default for UiState { about: false, about_tab: dialogs::AboutTab::About, preferences: false, + menu_input: None, shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs new file mode 100644 index 00000000..0de3757f --- /dev/null +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -0,0 +1,74 @@ +//! Menu and Actions entries that need an input must collect it in the UI, not send `{}` to the +//! engine (which would show a parameter error or silently use a default). + +use figcraft_engine::Session; +use serde_json::json; + +use crate::dialogs::MenuInput; +use crate::layers_drag_tests::Ui; +use crate::menus; + +#[test] +fn preference_actions_open_preferences_without_changing_values() { + let mut ui = Ui::new(Session::new()); + ui.app.session.prefs.language = "en".into(); + ui.app.session.prefs.publish_server = "wss://example.test/".into(); + let before = ui.app.session.prefs.clone(); + for id in ["pref.language", "pref.userName", "pref.publishServer", "pref.nudge"] { + ui.app.ui.preferences = false; + menus::invoke(&mut ui.app, id); + assert!(ui.app.ui.preferences, "{id} did not open Preferences"); + assert!(ui.app.ui.last_error.is_none(), "{id} showed an error"); + assert_eq!(ui.app.session.prefs.language, before.language); + assert_eq!(ui.app.session.prefs.publish_server, before.publish_server); + assert_eq!(ui.app.session.prefs.nudge_small, before.nudge_small); + assert_eq!(ui.app.session.prefs.nudge_big, before.nudge_big); + } + // Programmatic callers still need the actual argument. + assert!(ui.app.session.execute("pref.language", &json!({})).is_err()); +} + +#[test] +fn menu_entries_collect_required_arguments_before_running() { + let mut ui = Ui::new(Session::new()); + let name = ui.app.session.doc().unwrap().name.clone(); + for (id, expected) in [ + ("file.rename", MenuInput::RenameFile(name)), + ("text.link", MenuInput::Link(String::new())), + ("vector.offset", MenuInput::Offset(0.0)), + ("object.moveToPage", MenuInput::MoveToPage), + ] { + ui.app.ui.menu_input = None; + menus::invoke(&mut ui.app, id); + assert_eq!(ui.app.ui.menu_input, Some(expected), "{id}"); + assert!(ui.app.ui.last_error.is_none(), "{id} showed an error"); + } + ui.frame(vec![]); + assert!(ui.app.ui.last_error.is_none()); +} + +#[test] +fn palette_entries_with_existing_pickers_show_them() { + let mut ui = Ui::new(Session::new()); + menus::invoke(&mut ui.app, "tool.set"); + assert_eq!(ui.app.ui.tool_flyout, Some("move")); + ui.app.ui.left_collapsed = true; + menus::invoke(&mut ui.app, "page.select"); + assert!(!ui.app.ui.left_collapsed); + assert!(ui.app.ui.pages_open); + menus::invoke(&mut ui.app, "file.openRecent"); + assert!(ui.app.ui.home); + assert_eq!(ui.app.ui.home_view.section, crate::home::Section::Recents); + assert!(ui.app.ui.last_error.is_none()); +} + +#[test] +fn batch_rename_opens_its_existing_dialog() { + let mut session = Session::new(); + let id = session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone(); + session.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut ui = Ui::new(session); + menus::invoke(&mut ui.app, "object.batchRename"); + assert_eq!(ui.app.ui.batch_rename.as_ref().map(|d| d.ids.len()), Some(1)); + assert!(ui.app.ui.last_error.is_none()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index b9d3299a..ccdffa7f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -81,6 +81,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { "file.placeImage" => app.import_dialog(true), "file.save" => app.save(), "file.saveAs" => app.save_as_dialog(), + "file.openRecent" => { + app.ui.home = true; + app.ui.home_view.section = crate::home::Section::Recents; + } "file.saveVersion" => crate::versions::start_save(app), "branch.create" => crate::versions::start_branch(app), "file.export" | "file.exportSvg" => app.ui.export_preview = true, @@ -128,6 +132,31 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.services.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0))); } "edit.findReplace" => app.ui.find = Some((String::new(), String::new())), + // The Actions palette invokes commands without parameters. These values are edited in + // Preferences; sending an empty object would either fail or silently reset a preference. + "pref.language" | "pref.userName" | "pref.publishServer" | "pref.nudge" => { + app.run("app.preferences", json!({})); + } + "file.rename" => { + if let Ok(doc) = app.session.doc() { + app.ui.menu_input = Some(crate::dialogs::MenuInput::RenameFile(doc.name.clone())); + } + } + "text.link" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Link(String::new())), + "vector.offset" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Offset(0.0)), + "object.moveToPage" => app.ui.menu_input = Some(crate::dialogs::MenuInput::MoveToPage), + "object.batchRename" => { + let ids = app.session.selection(); + if !ids.is_empty() { + crate::dialogs::open_batch_rename(app, &ids); + } + } + "tool.set" => app.ui.tool_flyout = Some("move"), + "page.select" => { + app.ui.left_tab = crate::LeftTab::File; + app.ui.left_collapsed = false; + app.ui.pages_open = true; + } // From a menu the link is pasted into the Join dialog (`collab.join {link}` joins directly). "collab.join" => crate::live::open_join(app, ""), // So is the server address for publishing (it carries the server key). diff --git a/docs/gaps.md b/docs/gaps.md index e8b111a8..c8ed80b5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (menu and Actions parameter dialogs; bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -138,6 +138,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`, `cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`, `theme_pages_tests`). +- **Landed (2026-10-16):** affected menu and Actions entries that need values now open their existing + controls or collect the values before dispatch. The language choice in Preferences uses a + dropdown that fits in the dialog (`menu_input_tests.rs`). - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). @@ -400,6 +403,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Gap 4: affected menu and Actions entries with required values open an input or the relevant panel before running the command; the language selector fits inside Preferences. Estimates and ranking unchanged. | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | From b0f56ed1698b94d31ec6e6b9f2dcf0a1b21a40a5 Mon Sep 17 00:00:00 2001 From: sushi-killer <46325863+sushi-killer@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:19:32 +0300 Subject: [PATCH 15/28] fix(server): let message limits follow max_file_mb so files above 64 MiB publish and open --- apps/figcraft-server/src/config.rs | 26 ++++++++++++++---------- apps/figcraft-server/src/public_tests.rs | 18 +++++++++++++++- crates/collab-net/src/limits.rs | 6 ++++-- docs/server.md | 4 ++-- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index bd587b76..3e951e87 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -253,8 +253,9 @@ impl Config { } } - /// Largest message a client may send when open to the public: a whole file (or plugin bundle) - /// and a margin, not the core's 64 MiB. + /// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the + /// public this is also the cap (strangers' files stay small); a private server never goes below + /// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it. fn open_message(&self) -> usize { usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX) } @@ -265,9 +266,9 @@ impl Config { fn core(&self) -> figcraft_collab::Limits { let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() }; if !self.open_publish { - return base; + return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base }; } - let max = self.open_message().min(base.max_message_bytes); + let max = self.open_message(); let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX); figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base } } @@ -279,21 +280,24 @@ impl Config { pub fn net(&self) -> NetLimits { let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() }; if !self.open_publish { - return base; + let max = base.max_client_message.max(self.open_message()); + return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base }; } - let max = self.open_message().min(base.max_client_message); + let max = self.open_message(); // Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an - // eighth of each, and its biggest consumers are dropped first. - let (queued, reading) = (256usize << 20, 192usize << 20); + // eighth of each, and its biggest consumers are dropped first. Each still fits one whole + // message, or a file as large as `max_file_mb` could never arrive. + let one = max.saturating_add(1 << 20); + let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8))); NetLimits { max_per_address: self.max_connections_per_ip, max_client_message: max, - max_queued_bytes: max.saturating_add(1 << 20), + max_queued_bytes: one, max_queued_block: queued / 8, max_queued_total: queued, - max_reading_block: max.saturating_add(1 << 20).min(reading / 8), + max_reading_block: one.min(reading / 8), max_reading_total: reading, - max_in_flight: 128 << 20, + max_in_flight: (128usize << 20).max(one), reserve_for_sessions: self.max_connections / 8, request_idle: std::time::Duration::from_secs(20), ..base diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index fea2667e..d93dbe3e 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() { assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly"); assert_eq!(open.net().reserve_for_sessions, 512 / 8); assert_eq!(open.net().max_client_message, 17 << 20); - assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private"); + assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin"); let w = open.warnings().join("\n"); assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}"); let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open }; @@ -493,3 +493,19 @@ fn open_connections_never_reach_what_needs_the_key() { assert!(!format!("{info:?}").contains(&k)); server.stop(); } + +#[test] +fn files_above_64_mib_fit_every_limit() { + // `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must + // follow it, or the upload is dropped by the transport before the server can say why. + for open_publish in [false, true] { + let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() }; + let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20); + assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message); + assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}"); + assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); + assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); + } + // A client takes the largest file any server can keep. + assert!(NetLimits::default().max_server_message > 4096 << 20); +} diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index c3111876..42756b1e 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -12,7 +12,9 @@ pub struct NetLimits { /// before: its hello. pub max_client_message: usize, pub max_hello: usize, - /// Largest message a client accepts from the host (a welcome carries the whole document). + /// Largest message a client accepts from the host (a welcome carries the whole document): the + /// largest file a `figcraft-server` can be set to keep (`max_file_mb`, at most 4096) and a margin, + /// so a client can open every file a server takes. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -61,7 +63,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: figcraft_collab::Limits::default().max_message_bytes, + max_server_message: 4097 << 20, rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), diff --git a/docs/server.md b/docs/server.md index f2e4820c..55d784f4 100644 --- a/docs/server.md +++ b/docs/server.md @@ -282,7 +282,7 @@ are errors): | `data` | `figcraft-data` | The data directory | | `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) | | `max_files` | 1000 | Most files kept | -| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, and messages are at most 64 MiB | +| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server) | | `max_links` | 100 | Most share links per file | | `max_connections` | 512 | Most open connections | | `max_people` | 256 | Most people in one file at once | @@ -489,7 +489,7 @@ are larger. | Resource | Limit | |---|---| -| Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). The core's 64 MiB otherwise | +| Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). On a private server, the larger of that and the core's 64 MiB | | A file as it grows | Every batch is checked first (*always*): past `max_file_mb`, past its publisher's `max_mb_per_ip` or past `max_total_mb`, it is refused with the reason and the app rolls it back. A long log is compacted to make room at most once a minute per file (a compaction rewrites the whole file). A file's snapshot also holds the recent batches kept for reconnecting clients, at most a quarter of `max_file_mb` | | Recent batches in memory | A quarter of `max_file_mb` (256 KiB at least) per open file; older ones are dropped (a client that missed them catches up with the whole file) | | Bytes queued for slow clients | The largest message and 1 MiB per connection, 32 MiB per block of addresses, 256 MiB for everyone. A message sent to many people counts once for everyone and once per block (twenty colleagues behind one office address cost their block one copy of a broadcast), and welcomes (bounded by the file's size) don't count against blocks, so a whole office can join a file at once. A client that doesn't read its answers is dropped; when everyone's budget runs out, the connection holding the most is dropped | From 1b4db719576965d13e8ec39e319ef2e4ae67dc85 Mon Sep 17 00:00:00 2001 From: sushi-killer <46325863+sushi-killer@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:37:55 +0300 Subject: [PATCH 16/28] fix(collab-net): keep the client's message cap in range on 32-bit targets --- apps/figcraft-server/src/public_tests.rs | 6 ++++-- crates/collab-net/src/limits.rs | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index d93dbe3e..6403212c 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -506,6 +506,8 @@ fn files_above_64_mib_fit_every_limit() { assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); } - // A client takes the largest file any server can keep. - assert!(NetLimits::default().max_server_message > 4096 << 20); + // A client takes the largest file any server can keep, or as much as a 32-bit target can address + // (computed in u64: `4097 << 20` in a 32-bit usize wraps to 1 MiB). + let client = NetLimits::default().max_server_message as u64; + assert!(client > 4096 << 20 || client == u64::from(u32::MAX), "{client}"); } diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index 42756b1e..02167770 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -14,7 +14,8 @@ pub struct NetLimits { pub max_hello: usize, /// Largest message a client accepts from the host (a welcome carries the whole document): the /// largest file a `figcraft-server` can be set to keep (`max_file_mb`, at most 4096) and a margin, - /// so a client can open every file a server takes. + /// so a client can open every file a server takes; on a 32-bit target (the browser) as much as + /// `usize` holds. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -63,7 +64,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: 4097 << 20, + max_server_message: usize::try_from(4097u64 << 20).unwrap_or(usize::MAX), rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), From a9bcc48df7bc96f8428c312b77aff9c53740e36b Mon Sep 17 00:00:00 2001 From: sushi-killer <46325863+sushi-killer@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:45:08 +0300 Subject: [PATCH 17/28] feat(web): join a live session from ?join= in the page URL --- apps/figcraft-web/src/main.rs | 9 ++++++++- apps/figcraft-web/src/query.rs | 10 ++++++++++ apps/figcraft-web/src/query_tests.rs | 21 +++++++++++++++++++++ apps/figcraft-web/src/web.rs | 8 +++++++- 4 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 apps/figcraft-web/src/query.rs create mode 100644 apps/figcraft-web/src/query_tests.rs diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 587fd60b..35aebb02 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -13,8 +13,15 @@ //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to //! join from other machines. //! -//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! URL query: `?webgl` forces the WebGL2 backend instead of WebGPU; `?join=` joins that live session once the page has loaded, so a bookmark opens a shared file. +//! `join` takes the rest of the query: put it last. The link carries the session's secret, so the +//! URL (and the browser history) is as private as the link itself. +#[cfg(any(target_arch = "wasm32", test))] +mod query; +#[cfg(test)] +mod query_tests; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs new file mode 100644 index 00000000..be68f598 --- /dev/null +++ b/apps/figcraft-web/src/query.rs @@ -0,0 +1,10 @@ +//! The page's URL query: which flags it sets and the invite link it names. + +/// The invite link in `?join=` (`search` is `location.search`, with or without its `?`), still +/// escaped. `join` takes the rest of the query, so a pasted `figcraft://join?host=…&file=…&token=…` +/// keeps its own `&`s; put it last. +pub fn join_link(search: &str) -> Option<&str> { + let query = search.strip_prefix('?').unwrap_or(search); + let start = if query.starts_with("join=") { 0 } else { query.find("&join=")? + 1 }; + query.get(start + "join=".len()..).filter(|link| !link.is_empty()) +} diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs new file mode 100644 index 00000000..9582682f --- /dev/null +++ b/apps/figcraft-web/src/query_tests.rs @@ -0,0 +1,21 @@ +//! `?join=` in the page's URL. + +use crate::query::join_link; + +#[test] +fn join_takes_the_rest_of_the_query() { + let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; + assert_eq!(join_link(&format!("?join={link}")), Some(link), "a pasted link keeps its own &s"); + assert_eq!(join_link(&format!("?webgl&join={link}")), Some(link), "after other flags"); + assert_eq!(join_link("?join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); + assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading ?"); +} + +#[test] +fn no_join_without_the_parameter() { + assert_eq!(join_link(""), None); + assert_eq!(join_link("?webgl"), None); + assert_eq!(join_link("?join="), None); + assert_eq!(join_link("?rejoin=x"), None, "only the whole parameter name"); + assert_eq!(join_link("?xjoin=x&a=1"), None); +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 8cadb013..d3c16441 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -40,7 +40,13 @@ pub fn start() { // on the socket wakes the page for a frame. let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); - let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); + // `?join=`: join that session, as opening an invite link does on the desktop. + let search = query(); + if let Some(link) = crate::query::join_link(&search) { + let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); + figcraft_ui_egui::live::open_invite(&mut fig, &link); + } Ok(Box::new(WebShell { fig })) }), ) From 01ce23ca765fabba5ec22bd83de5684614e259d3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:58:23 +0700 Subject: [PATCH 18/28] #680: page refusal names the calling command (edit.copy / edit.cut) --- crates/engine/src/cmd/edit.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index ab503a02..22610934 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -496,13 +496,13 @@ fn bump_name(n: &str) -> String { } /// The copied form of the selection. -pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { +pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; let doc = &ds.doc; // Pages are not layers: they can't be pasted anywhere. let layers: Vec = ids.iter().copied().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); if layers.is_empty() && !ids.is_empty() { - return Err(EngineError::Disabled("edit.copy".into(), "pages can't be copied; use page.duplicate".into())); + return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); } let ids = z_sorted(doc, &roots(doc, &layers)); let mut nodes = Vec::new(); @@ -540,13 +540,13 @@ pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { fn copy(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.copy", s, p)?; - copy_to_clipboard(s, &ids)?; + copy_to_clipboard(s, "edit.copy", &ids)?; Ok(json!({"copied": s.clipboard.nodes.len()})) } fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; - copy_to_clipboard(s, &ids)?; + copy_to_clipboard(s, "edit.cut", &ids)?; let doc = s.doc()?; let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); delete(s, &json!({"ids": layers})) From 5ea5590673bff20d96097b37cc6e2177e9c69348 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 15:58:24 +0700 Subject: [PATCH 19/28] #680: move the page clipboard test to its own module, check the error id --- crates/engine/src/lib.rs | 2 ++ crates/engine/src/page_clipboard_tests.rs | 36 +++++++++++++++++++++++ crates/engine/src/tests.rs | 17 ----------- 3 files changed, 38 insertions(+), 17 deletions(-) create mode 100644 crates/engine/src/page_clipboard_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6e0e8b12..9f00467a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1592,6 +1592,8 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_clipboard_tests; +#[cfg(test)] mod page_copy_navigation_tests; #[cfg(test)] mod page_target_tests; diff --git a/crates/engine/src/page_clipboard_tests.rs b/crates/engine/src/page_clipboard_tests.rs new file mode 100644 index 00000000..79ff7419 --- /dev/null +++ b/crates/engine/src/page_clipboard_tests.rs @@ -0,0 +1,36 @@ +//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a +//! selection of only pages under their own command id. + +use serde_json::json; + +use crate::{EngineError, Session}; + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = Session::new(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + match s.execute(cmd, &json!({"ids": [page.0]})) { + Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd), + r => panic!("{cmd} on a page: {r:?}"), + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} + +#[test] +fn cut_of_a_page_and_a_layer_moves_only_the_layer() { + let mut s = Session::new(); + let page = s.page().unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let rect = crate::params::id_of(&r["id"]).unwrap(); + s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap(); + assert!(s.doc().unwrap().node(page).is_some()); + assert!(s.doc().unwrap().node(rect).is_none()); + s.execute("edit.paste", &json!({})).unwrap(); + assert!(s.doc().unwrap().validate().is_empty()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 6010559e..dbf432d4 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,20 +621,3 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } - -#[test] -fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { - for cmd in ["edit.copy", "edit.cut"] { - let mut s = sess(); - let page = s.page().unwrap(); - s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - let r = s.execute(cmd, &json!({"ids": [page.0]})); - if let Ok(v) = &r { - assert!(v.get("deleted").is_none_or(|d| d == &json!([])), "{cmd} reported the page as deleted: {v}"); - } - let _ = s.execute("edit.paste", &json!({})); - let doc = s.doc().unwrap(); - assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); - assert!(doc.node(page).is_some(), "{cmd} removed the page"); - } -} From 6f0e14a27176a4399888d46955fe8a29445665c0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:03:41 +0700 Subject: [PATCH 20/28] #673: read the failed step with get() instead of indexing --- apps/figcraft-cli/src/files.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 46c8dd22..3e08474a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -350,7 +350,8 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe Ok(()) }); if let Err(e) = outcome { - let (i, (cmd, _)) = (failed_at, &steps[failed_at]); + let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); eprintln!("error: step {i} ({cmd}) failed: {e}"); eprintln!("the script's document changes were rolled back"); From 1503f0743cbc440892dbc60b4dc8615d5d0f2955 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:06:26 +0700 Subject: [PATCH 21/28] #675: cap the offending value echoed in id/ids errors --- .../engine/src/id_params_behaviour_tests.rs | 10 ++++++++++ crates/engine/src/params.rs | 19 +++++++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/id_params_behaviour_tests.rs b/crates/engine/src/id_params_behaviour_tests.rs index ae77c8a1..c5207b44 100644 --- a/crates/engine/src/id_params_behaviour_tests.rs +++ b/crates/engine/src/id_params_behaviour_tests.rs @@ -149,3 +149,13 @@ fn an_image_and_an_instance_parent_are_checked_the_same_way() { let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}}))); assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent"); } + +#[test] +fn a_huge_bad_value_is_cut_short_in_the_error() { + let mut s = Session::new(); + card(&mut s); + let junk = "é".repeat(10_000); + let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail")); + assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}"); + assert!(msg.chars().count() < 200, "{} chars", msg.chars().count()); +} diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index 67a5b80a..35a74e0e 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -88,6 +88,17 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id"))) } +/// The offending value for an error message, cut to a few dozen characters so a huge param +/// doesn't end up in the error whole. +fn shown(v: &Value) -> String { + const MAX: usize = 40; + let text = v.to_string(); + match text.char_indices().nth(MAX) { + Some((cut, _)) => format!("{}…", &text[..cut]), + None => text, + } +} + /// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a /// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" — /// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't @@ -95,7 +106,7 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result { pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result> { match get(p, key) { None | Some(Value::Null) => Ok(None), - Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {v})"))), + Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))), } } @@ -125,17 +136,17 @@ pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result> { match get(p, "ids") { Some(Value::Array(a)) => { if let Some(v) = a.iter().find(|v| id_of(v).is_none()) { - return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {v})"))); + return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v)))); } } Some(Value::Null) | None => { if let Some(v) = get(p, "id").filter(|v| !v.is_null()) && id_of(v).is_none() { - return Err(bad(cmd, format!("`id` must be a node id (got {v})"))); + return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v)))); } } - Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {v})"))), + Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))), } let t = targets(s, p); if t.is_empty() { From 3c73045088480866d6dfd9bf9604a9513ced0677 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:11:59 +0700 Subject: [PATCH 22/28] #705: cap what a client buffers from a server at 1 GiB; warn the operator about files over 64 MiB when open and over what the app opens --- apps/figcraft-server/src/config.rs | 14 ++++++++++++++ apps/figcraft-server/src/public_tests.rs | 21 +++++++++++++++++---- crates/collab-net/src/limits.rs | 9 ++++----- docs/server.md | 2 +- 4 files changed, 36 insertions(+), 10 deletions(-) diff --git a/apps/figcraft-server/src/config.rs b/apps/figcraft-server/src/config.rs index 3e951e87..101c7815 100644 --- a/apps/figcraft-server/src/config.rs +++ b/apps/figcraft-server/src/config.rs @@ -135,9 +135,23 @@ impl Config { /// abuse contact, or one whose clients would all count as its proxy). pub fn warnings(&self) -> Vec { let mut out = Vec::new(); + let client_max = NetLimits::default().max_server_message >> 20; + if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) { + out.push(format!( + "max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened", + self.file_mb(), + client_max - 1 + )); + } if !self.open_publish { return out; } + if self.file_mb() > 64 { + out.push(format!( + "open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server", + self.file_mb() + )); + } if self.operator_contact.trim().is_empty() { out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into()); } diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 6403212c..978d9e8e 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -506,8 +506,21 @@ fn files_above_64_mib_fit_every_limit() { assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block); assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes); } - // A client takes the largest file any server can keep, or as much as a 32-bit target can address - // (computed in u64: `4097 << 20` in a 32-bit usize wraps to 1 MiB). - let client = NetLimits::default().max_server_message as u64; - assert!(client > 4096 << 20 || client == u64::from(u32::MAX), "{client}"); + // A client takes such a file too, but caps what any server may make it buffer at 1 GiB. + assert_eq!(NetLimits::default().max_server_message, 1 << 30); +} + +#[test] +fn large_files_warn_the_operator() { + let public_url = Some("wss://figcraft.example.com/".into()); + let warns = |open_publish, mb| { + Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join( + " +", + ) + }; + assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64)); + assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65)); + assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023)); + assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024)); } diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index 02167770..a0647fa6 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -12,10 +12,9 @@ pub struct NetLimits { /// before: its hello. pub max_client_message: usize, pub max_hello: usize, - /// Largest message a client accepts from the host (a welcome carries the whole document): the - /// largest file a `figcraft-server` can be set to keep (`max_file_mb`, at most 4096) and a margin, - /// so a client can open every file a server takes; on a 32-bit target (the browser) as much as - /// `usize` holds. + /// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB, + /// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server + /// (any link can name one) can't make the app or a browser tab buffer more than that. pub max_server_message: usize, /// Messages a client may send per second on average, and in a burst; beyond that it is /// disconnected. @@ -64,7 +63,7 @@ impl Default for NetLimits { max_connections: 64, max_client_message: figcraft_collab::Limits::default().max_message_bytes, max_hello: 64 << 10, - max_server_message: usize::try_from(4097u64 << 20).unwrap_or(usize::MAX), + max_server_message: 1 << 30, rate: 100.0, burst: 400.0, hello_timeout: Duration::from_secs(10), diff --git a/docs/server.md b/docs/server.md index 55d784f4..5cbab6a8 100644 --- a/docs/server.md +++ b/docs/server.md @@ -282,7 +282,7 @@ are errors): | `data` | `figcraft-data` | The data directory | | `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) | | `max_files` | 1000 | Most files kept | -| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server) | +| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server). The app opens files of up to 1023 MiB; above 64 with `open_publish` the server warns | | `max_links` | 100 | Most share links per file | | `max_connections` | 512 | Most open connections | | `max_people` | 256 | Most people in one file at once | From 02b231db6dd9457097017ddc30d96d8a947c724d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:18:50 +0700 Subject: [PATCH 23/28] #712: read the invite link from the URL fragment (#join=), never sent to the web host; ignore overlong links --- apps/figcraft-web/src/main.rs | 10 ++++++---- apps/figcraft-web/src/query.rs | 21 +++++++++++-------- apps/figcraft-web/src/query_tests.rs | 30 ++++++++++++++++++---------- apps/figcraft-web/src/web.rs | 6 +++--- 4 files changed, 41 insertions(+), 26 deletions(-) diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 35aebb02..7eed2ffb 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -13,10 +13,12 @@ //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to //! join from other machines. //! -//! URL query: `?webgl` forces the WebGL2 backend instead of WebGPU; `?join=` joins that live session once the page has loaded, so a bookmark opens a shared file. -//! `join` takes the rest of the query: put it last. The link carries the session's secret, so the -//! URL (and the browser history) is as private as the link itself. +//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! +//! URL fragment: `#join=` joins that live session once the page has +//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last. +//! The link carries the session's secret; a fragment is never sent to the web host, but the address +//! bar and the browser history keep it, so the URL is as private as the link itself. #[cfg(any(target_arch = "wasm32", test))] mod query; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs index be68f598..befeb36b 100644 --- a/apps/figcraft-web/src/query.rs +++ b/apps/figcraft-web/src/query.rs @@ -1,10 +1,15 @@ -//! The page's URL query: which flags it sets and the invite link it names. +//! The page's URL: the invite link its fragment names. -/// The invite link in `?join=` (`search` is `location.search`, with or without its `?`), still -/// escaped. `join` takes the rest of the query, so a pasted `figcraft://join?host=…&file=…&token=…` -/// keeps its own `&`s; put it last. -pub fn join_link(search: &str) -> Option<&str> { - let query = search.strip_prefix('?').unwrap_or(search); - let start = if query.starts_with("join=") { 0 } else { query.find("&join=")? + 1 }; - query.get(start + "join=".len()..).filter(|link| !link.is_empty()) +/// The invite link in `#join=` (`fragment` is `location.hash`, with or without its `#`), still +/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out +/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted +/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite +/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is +/// opened with. +pub fn join_link(fragment: &str) -> Option<&str> { + // An invite link is at most 2 KiB; every byte of it escaped is three. + const MAX: usize = 3 * 2048; + let fragment = fragment.strip_prefix('#').unwrap_or(fragment); + let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 }; + fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX) } diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs index 9582682f..261ebaeb 100644 --- a/apps/figcraft-web/src/query_tests.rs +++ b/apps/figcraft-web/src/query_tests.rs @@ -1,21 +1,29 @@ -//! `?join=` in the page's URL. +//! `#join=` in the page's URL. use crate::query::join_link; #[test] -fn join_takes_the_rest_of_the_query() { +fn join_takes_the_rest_of_the_fragment() { let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; - assert_eq!(join_link(&format!("?join={link}")), Some(link), "a pasted link keeps its own &s"); - assert_eq!(join_link(&format!("?webgl&join={link}")), Some(link), "after other flags"); - assert_eq!(join_link("?join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); - assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading ?"); + assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s"); + assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries"); + assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later"); + assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #"); } #[test] -fn no_join_without_the_parameter() { +fn no_join_without_the_entry() { assert_eq!(join_link(""), None); - assert_eq!(join_link("?webgl"), None); - assert_eq!(join_link("?join="), None); - assert_eq!(join_link("?rejoin=x"), None, "only the whole parameter name"); - assert_eq!(join_link("?xjoin=x&a=1"), None); + assert_eq!(join_link("#"), None); + assert_eq!(join_link("#join="), None); + assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name"); + assert_eq!(join_link("#xjoin=x&a=1"), None); + assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host"); +} + +#[test] +fn an_overlong_link_is_ignored() { + let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000)); + assert_eq!(join_link(&long), None); + assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes"); } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index d3c16441..29213621 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -41,9 +41,9 @@ pub fn start() { let ctx = cc.egui_ctx.clone(); session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint())))); let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() })); - // `?join=`: join that session, as opening an invite link does on the desktop. - let search = query(); - if let Some(link) = crate::query::join_link(&search) { + // `#join=`: join that session, as opening an invite link does on the desktop. + let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default(); + if let Some(link) = crate::query::join_link(&hash) { let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); figcraft_ui_egui::live::open_invite(&mut fig, &link); } From b6d98e28dccc8303c252e590a6c802f380c5cced Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:29:31 +0700 Subject: [PATCH 24/28] #699: keep en.toml's end of file as on main --- crates/ui-egui/locales/en.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 53cc5c45..efd23d04 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1606,4 +1606,4 @@ name = "English" "⌘ arrows" = "⌘ arrows" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" -"⌥ drag" = "⌥ drag" +"⌥ drag" = "⌥ drag" \ No newline at end of file From 7f7921260ca81914d48e6a69d61c01431f28f611 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:29:34 +0700 Subject: [PATCH 25/28] #699: menu input dialog and language picker use the shared widgets; File > Rename edits the file name in place --- crates/ui-egui/src/dialogs.rs | 70 +++++++++++++++----------- crates/ui-egui/src/layers.rs | 3 +- crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/menu_input_tests.rs | 27 +++++++--- crates/ui-egui/src/menus.rs | 7 +-- 5 files changed, 69 insertions(+), 41 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 31eb1adb..649bcb3f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -74,7 +74,6 @@ pub enum AboutTab { /// Values collected by a menu or Actions command before dispatching it to the engine. #[derive(Clone, Debug, PartialEq)] pub enum MenuInput { - RenameFile(String), Link(String), Offset(f64), MoveToPage, @@ -274,18 +273,27 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.horizontal(|ui| { ui.label(tr("Language")); // Each language by its own name; System follows the OS (the browser on the web). - let langs = i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name)); - let choices: Vec<_> = [("system", tr("System"))].into_iter().chain(langs).collect(); - let selected = - choices.iter().find(|(code, _)| app.session.prefs.language.eq_ignore_ascii_case(code)).map_or(tr("System"), |(_, name)| *name); - egui::ComboBox::from_id_salt("pref_language").selected_text(selected).width(220.0).show_ui(ui, |ui| { - for (code, name) in choices { - if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { - app.run("pref.language", json!({"language": code})); - ui.ctx().request_repaint(); + let langs = || { + [("system", tr("System"))] + .into_iter() + .chain(i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name))) + }; + let on = |code: &str| app.session.prefs.language.eq_ignore_ascii_case(code); + let selected = langs().find(|(code, _)| on(code)).map_or(tr("System"), |(_, name)| name); + let resp = crate::widgets::dropdown(ui, selected, 220.0, None); + let mut pick = None; + crate::right::menu_popup(&resp).show(|ui| { + for (code, name) in langs() { + if crate::right::menu_check(ui, on(code), name, "") { + pick = Some(code); + ui.close(); } } }); + if let Some(code) = pick { + app.run("pref.language", json!({"language": code})); + ui.ctx().request_repaint(); + } }); if let Some(lang) = i18n::current().filter(|l| l.draft) { let note = trf("{language}: draft translation, waiting for a native speaker's review.", &[("language", &lang.name)]); @@ -355,8 +363,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { /// command so the same validation and undo behaviour apply as for programmatic callers. fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { let Some(mut input) = app.ui.menu_input.take() else { return }; + let t = Tokens::get(ctx); let title = match &input { - MenuInput::RenameFile(_) => i18n::command("file.rename", "Rename file"), MenuInput::Link(_) => i18n::command("text.link", "Create link"), MenuInput::Offset(_) => i18n::command("vector.offset", "Offset path"), MenuInput::MoveToPage => i18n::command("object.moveToPage", "Move to page"), @@ -366,26 +374,28 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { window(&title, ctx).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); match &mut input { - MenuInput::RenameFile(name) => { - let field = ui.add(egui::TextEdit::singleline(name).desired_width(300.0)); - if (ui.button(tr("Rename")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) - && !name.trim().is_empty() - { - action = Some(("file.rename", json!({"name": name.trim()}))); - } - } MenuInput::Link(url) => { - let field = ui.add(egui::TextEdit::singleline(url).hint_text("https://").desired_width(300.0)); - if (ui.button(tr("Done")).clicked() || field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) && !url.trim().is_empty() - { - action = Some(("text.link", json!({"url": url.trim()}))); - } + ui.horizontal(|ui| { + let field = crate::widgets::text_input(ui, egui::Id::new("menu_input_link"), url, 250.0, "https://"); + let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); + let ok = !url.trim().is_empty(); + if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok { + action = Some(("text.link", json!({"url": url.trim()}))); + } + }); } MenuInput::Offset(offset) => { - ui.add(egui::DragValue::new(offset).speed(1.0).range(-100_000.0..=100_000.0)); - if ui.button(tr("Offset")).clicked() { - action = Some(("vector.offset", json!({"offset": offset}))); - } + ui.horizontal(|ui| { + let id = egui::Id::new("menu_input_offset"); + if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) = + crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0) + { + *offset = v.clamp(-100_000.0, 100_000.0); + } + if crate::widgets::button(ui, tr("Offset"), true, true).clicked() { + action = Some(("vector.offset", json!({"offset": *offset}))); + } + }); } MenuInput::MoveToPage => { let mut choices = 0; @@ -395,7 +405,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { continue; } if let Some(node) = ds.doc.node(page) - && ui.button(&node.name).clicked() + && crate::widgets::button(ui, &node.name, false, true).clicked() { action = Some(("object.moveToPage", json!({"page": page}))); } @@ -403,7 +413,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { } } if choices == 0 { - ui.label(tr("No other pages")); + crate::widgets::label(ui, tr("No other pages"), t.text_secondary); } } } diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 8b614762..f05c9f87 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -148,7 +148,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } else { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); let nr = Rect::from_min_max(pos2(head.min.x + 14.0, head.min.y + 14.0), pos2(head.max.x - 48.0, head.min.y + 34.0)); - if let Some(new) = crate::widgets::text_field_inline(ui, Id::new("filename"), &name, nr, semibold(13.0)) { + let start = std::mem::take(&mut app.ui.rename_file); + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new("filename"), &name, nr, semibold(13.0), start) { app.run("file.rename", json!({"name": new})); } ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, tr("Drafts"), regular(11.0), t.text_secondary); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c4671391..982c275f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -414,6 +414,8 @@ pub struct UiState { pub preferences: bool, /// A menu command that needs a value before the engine can run it. pub menu_input: Option, + /// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame. + pub rename_file: bool, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -542,6 +544,7 @@ impl Default for UiState { about_tab: dialogs::AboutTab::About, preferences: false, menu_input: None, + rename_file: false, shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index 0de3757f..f317b65b 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -31,13 +31,9 @@ fn preference_actions_open_preferences_without_changing_values() { #[test] fn menu_entries_collect_required_arguments_before_running() { let mut ui = Ui::new(Session::new()); - let name = ui.app.session.doc().unwrap().name.clone(); - for (id, expected) in [ - ("file.rename", MenuInput::RenameFile(name)), - ("text.link", MenuInput::Link(String::new())), - ("vector.offset", MenuInput::Offset(0.0)), - ("object.moveToPage", MenuInput::MoveToPage), - ] { + for (id, expected) in + [("text.link", MenuInput::Link(String::new())), ("vector.offset", MenuInput::Offset(0.0)), ("object.moveToPage", MenuInput::MoveToPage)] + { ui.app.ui.menu_input = None; menus::invoke(&mut ui.app, id); assert_eq!(ui.app.ui.menu_input, Some(expected), "{id}"); @@ -72,3 +68,20 @@ fn batch_rename_opens_its_existing_dialog() { assert_eq!(ui.app.ui.batch_rename.as_ref().map(|d| d.ids.len()), Some(1)); assert!(ui.app.ui.last_error.is_none()); } + +#[test] +fn rename_file_edits_the_file_name_in_place() { + let mut ui = Ui::new(Session::new()); + ui.app.ui.left_collapsed = true; + menus::invoke(&mut ui.app, "file.rename"); + assert!(!ui.app.ui.left_collapsed, "the left panel shows the file name"); + assert!(ui.app.ui.menu_input.is_none(), "no dialog"); + ui.frame(vec![]); + ui.frame(vec![]); + assert!(!ui.app.ui.rename_file, "the rename started"); + assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::new("filename").with("te"))), "the file name's editor has the keyboard"); + ui.frame(vec![egui::Event::Text("Brand kit".into())]); + ui.frame(vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + assert_eq!(ui.app.session.doc().unwrap().name, "Brand kit"); + assert!(ui.app.ui.last_error.is_none()); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 18023e67..5a9017a8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -137,10 +137,11 @@ pub fn invoke(app: &mut FigApp, id: &str) { "pref.language" | "pref.userName" | "pref.publishServer" | "pref.nudge" => { app.run("app.preferences", json!({})); } + // The file name in the left panel's header turns into its editor, the name selected. "file.rename" => { - if let Ok(doc) = app.session.doc() { - app.ui.menu_input = Some(crate::dialogs::MenuInput::RenameFile(doc.name.clone())); - } + app.ui.left_tab = crate::LeftTab::File; + app.ui.left_collapsed = false; + app.ui.rename_file = true; } "text.link" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Link(String::new())), "vector.offset" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Offset(0.0)), From 234339a78681c634075336e442ffe1f3685d706b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:47:07 +0000 Subject: [PATCH 26/28] Refuse pages in edit.copy and edit.cut Found while testing crates/engine/src/cmd/edit.rs:499 (copy_to_clipboard, called from copy at :536 and cut at :542) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/edit.rs | 11 +++++++++-- crates/engine/src/tests.rs | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 21f09569..eef4beaa 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -505,7 +505,12 @@ fn bump_name(n: &str) -> String { /// The copied form of the selection. pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> { let ds = s.ds()?; - let (nodes, images, bounds) = copied(&ds.doc, ids); + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled("edit.copy".into(), "pages can't be copied; use page.duplicate".into())); + } + let (nodes, images, bounds) = copied(&ds.doc, &layers); let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); s.clipboard.source = Some(source); s.clipboard.source_path = source_path; @@ -605,7 +610,9 @@ fn copy(s: &mut Session, p: &Value) -> Result { fn cut(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.cut", s, p)?; copy_to_clipboard(s, &ids)?; - delete(s, &json!({"ids": ids})) + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) } /// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dbf432d4..6010559e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -621,3 +621,20 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +#[test] +fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() { + for cmd in ["edit.copy", "edit.cut"] { + let mut s = sess(); + let page = s.page().unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let r = s.execute(cmd, &json!({"ids": [page.0]})); + if let Ok(v) = &r { + assert!(v.get("deleted").is_none_or(|d| d == &json!([])), "{cmd} reported the page as deleted: {v}"); + } + let _ = s.execute("edit.paste", &json!({})); + let doc = s.doc().unwrap(); + assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate()); + assert!(doc.node(page).is_some(), "{cmd} removed the page"); + } +} From fec54d93619a9356fbefedd50c4f5993ad1f4ec7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:36:54 +0700 Subject: [PATCH 27/28] #599: leave the progress docs to the maintainer --- ROADMAP.md | 2 +- docs/localization-parity.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2411799f..006b81b9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 100% | draft (needs native review) | | Vietnamese | vi | 0% | none | -Also shipped: Swedish (`sv`: every UI string, command label and menu wording; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d3f69a9a..45a7a925 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -100,8 +100,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 | -Other languages: Swedish (`sv`): every UI string, command label and menu wording translated; native -review welcome. Figma ships none beyond the eight +Other languages: Swedish (`sv`): every UI string translated, and 120 of the 588 command labels and +menu wordings (the rest show in English); native review welcome. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries From e912a2efe8b2574b9835caf629c7a871fb436bb7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 17 Oct 2026 16:38:35 +0700 Subject: [PATCH 28/28] #655: fill.set / stroke.set read index with params::opt_index (#675) --- crates/engine/src/cmd/edit.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 1d7cb88e..12ff561e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1184,7 +1184,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result Result { let cmd = if fills { "fill.set" } else { "stroke.set" }; let ids = req_targets(cmd, s, p)?; - let idx = if get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; + let idx = opt_index(cmd, p, "index")?; let new_paint = match get(p, "paint") { Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), None => None,