From 1f78ca6abaa7f9bb8e790dac9dc5dd826e171755 Mon Sep 17 00:00:00 2001 From: AKY <85772856+arksky1@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:34:40 +0900 Subject: [PATCH 01/15] Add Korean support Preserve existing Korean translations and exclude unused keys and keys already proposed in open PRs. Static catalog, key, placeholder, ellipsis, shortcut and whitespace checks passed. Cargo fmt, clippy, tests, xtasks and native/web UI validation were not run: Rust tools are unavailable and installation awaits approval. See docs/korean-translation-validation.md. --- crates/ui-egui/locales/ko.toml | 141 ++++++++++++++++++++++++++ docs/korean-translation-validation.md | 25 +++++ 2 files changed, 166 insertions(+) create mode 100644 docs/korean-translation-validation.md diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index b33b36b9..e7ad4f08 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1641,6 +1641,122 @@ status = "draft — needs native review" "⌘ click" = "⌘ 클릭" "⌘ scroll" = "⌘ 스크롤" "⌥ drag" = "⌥ 드래그" +"A responsive product page: hero, features, figures, a quote and a footer" = "대표 영역, 기능, 수치, 인용문, 푸터로 구성된 반응형 제품 페이지" +"A title, an agenda, a big figure, a chart and a closing slide" = "제목, 목차, 주요 수치, 차트, 마무리 슬라이드" +"A to Z" = "이름 오름차순" +"Adding to {name}" = "{name}에 추가 중" +"All the fonts this file uses are installed" = "이 파일에 사용된 모든 글꼴이 설치되어 있습니다." +"Alphabetical" = "가나다순" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "수치, 벡터 차트, 표를 격자로 배치한 분석 화면" +"Apps" = "앱" +"Apr" = "4월" +"Aug" = "8월" +"Bottom padding" = "아래쪽 안쪽 여백" +"Bound to {name}. Click to detach the variable." = "{name}에 연결되어 있습니다. 클릭하면 변수 연결을 해제합니다." +"Checking the fonts installed on this computer…" = "이 컴퓨터에 설치된 글꼴 확인 중…" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "색상 및 텍스트 스타일, 밝게·어둡게 변수, 버튼, 입력란, 카드" +"Convert to frame" = "프레임으로 변환" +"Convert to section" = "섹션으로 변환" +"Couldn't download the variables" = "변수를 다운로드할 수 없습니다." +"Created" = "만든 날짜" +"Dashboard" = "대시보드" +"Date created" = "만든 날짜" +"Dec" = "12월" +"Design system starter" = "디자인 시스템 시작 키트" +"Draw a frame" = "프레임 그리기" +"Drop a .figcraft, .fig, .json, .svg or image file." = ".figcraft, .fig, .json, .svg 또는 이미지 파일을 놓으세요." +"Drop to import" = "놓아서 가져오기" +"Drop to open" = "놓아서 열기" +"Edited {when}" = "{when} 편집" +"Export frames and layers as PNG, JPG, SVG or PDF" = "프레임과 레이어를 PNG, JPG, SVG 또는 PDF로 내보내기" +"Feb" = "2월" +"File not found" = "파일을 찾을 수 없습니다." +"Four screens of a trail app with components and a prototype flow" = "컴포넌트와 프로토타입 흐름이 포함된 트레일 앱 화면 4개" +"Getting started" = "시작하기" +"Greyscale components and two example screens to sketch a flow with" = "흐름을 구상할 수 있는 회색 음영 컴포넌트와 예시 화면 2개" +"Hide these tips (Getting started in the Help menu shows them again)" = "팁 숨기기(도움말 메뉴의 시작하기에서 다시 표시)" +"Hide when publishing" = "게시할 때 숨기기" +"Icon components on a 24 px grid with keylines and a size preview" = "키라인과 크기 미리 보기가 포함된 24px 격자의 아이콘 컴포넌트" +"Icon set starter" = "아이콘 세트 시작 키트" +"Individual padding" = "개별 안쪽 여백" +"Interface size" = "인터페이스 크기" +"Jan" = "1월" +"Jul" = "7월" +"Jun" = "6월" +"Kits" = "키트" +"Landing page" = "랜딩 페이지" +"Last modified" = "마지막 수정" +"Left padding" = "왼쪽 안쪽 여백" +"Link frames on the Prototype tab, then present the flow" = "프로토타입 탭에서 프레임을 연결한 다음 흐름을 실행해 보세요." +"Make a component" = "컴포넌트 만들기" +"Mar" = "3월" +"May" = "5월" +"Missing fonts" = "없는 글꼴" +"Mobile app" = "모바일 앱" +"Newest first" = "최신순" +"No files or templates match your search" = "검색 조건에 맞는 파일이나 템플릿이 없습니다." +"No other pages" = "다른 페이지 없음" +"No templates match your search" = "검색 조건에 맞는 템플릿이 없습니다." +"Nov" = "11월" +"Oct" = "10월" +"Oldest first" = "오래된순" +"One announcement in five common sizes: square, portrait, story, link and banner" = "하나의 공지를 정사각형, 세로형, 스토리, 링크, 배너의 일반적인 5가지 크기로 구성" +"Opening the file…" = "파일 여는 중…" +"Opening {n} files as new files" = { other = "파일 {n}개를 새 파일로 여는 중" } +"Order" = "순서" +"Other" = "기타" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "프레임 도구를 선택한 뒤 캔버스에서 드래그하거나 기기 크기를 고르세요." +"Plugin" = "플러그인" +"Plugins & widgets" = "플러그인 및 위젯" +"Presentation slides" = "프레젠테이션 슬라이드" +"Playground" = "실험 공간" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "시스템 디스플레이 배율을 기준으로 합니다(100%는 시스템 기본 크기). 문서 확대/축소는 별도입니다." +"Remove from recents" = "최근 목록에서 제거" +"Reorder selection" = "선택 항목 순서 변경" +"Right padding" = "오른쪽 안쪽 여백" +"Search templates" = "템플릿 검색" +"See all templates" = "모든 템플릿 보기" +"Select layers and they space themselves as their content changes" = "레이어를 선택하면 내용 변경에 맞춰 간격이 자동으로 조정됩니다." +"Search files and templates" = "파일 및 템플릿 검색" +"Search results" = "검색 결과" +"Search pages" = "페이지 검색" +"Sep" = "9월" +"Share with a code" = "코드로 공유" +"Show all templates" = "모든 템플릿 표시" +"Show in Explorer" = "탐색기에서 보기" +"Show in Finder" = "Finder에서 보기" +"Show in folder" = "폴더에서 보기" +"Social and slides" = "소셜 및 슬라이드" +"Social post sizes" = "소셜 게시물 크기" +"Sort by" = "정렬 기준" +"Sort by {column}" = "{column} 기준 정렬" +"Start from a template" = "템플릿으로 시작" +"Start sharing with the Share button, then pass on the code it gives" = "공유 버튼으로 공유를 시작한 뒤 표시되는 코드를 전달하세요." +"Templates" = "템플릿" +"The destination document is unavailable. Nothing was imported." = "대상 문서를 사용할 수 없습니다. 아무것도 가져오지 않았습니다." +"The destination document was closed. Nothing was imported." = "대상 문서가 닫혔습니다. 아무것도 가져오지 않았습니다." +"The destination page was removed. Nothing was imported." = "대상 페이지가 삭제되었습니다. 아무것도 가져오지 않았습니다." +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "창이 너무 작아 {size}% 대신 {drawn}%로 표시합니다. {size}%를 사용하려면 창을 키우세요." +"This file is too large to join here (up to {mb} MB)" = "이 파일은 여기에 추가하기에 너무 큽니다(최대 {mb}MB)." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "이 파일은 이 컴퓨터에 없는 글꼴을 사용합니다. 해당 글꼴을 설치할 때까지 대체 글꼴로 표시합니다." +"Top padding" = "위쪽 안쪽 여백" +"Try another search, or browse templates." = "다른 검색어를 입력하거나 템플릿을 둘러보세요." +"Try another word, or look at every kind." = "다른 단어를 검색하거나 모든 종류를 확인하세요." +"Turn a layer into a component, reuse it, and change every copy at once" = "레이어를 컴포넌트로 만들고 재사용하여 모든 사본을 한 번에 변경하세요." +"Use your browser's zoom to change the interface size." = "인터페이스 크기는 브라우저의 확대/축소로 변경하세요." +"Uses variables: each layer shows them in its own mode" = "변수 사용: 각 레이어의 모드에 따라 표시합니다." +"Websites" = "웹사이트" +"What you try here changes this preview and the code below, not the file" = "여기서 변경한 내용은 미리 보기와 아래 코드에만 적용되며 파일은 바뀌지 않습니다." +"Widget" = "위젯" +"Wireframe kit" = "와이어프레임 키트" +"Z to A" = "이름 내림차순" +"just now" = "방금" +"only in a text style or variable" = "텍스트 스타일 또는 변수에서만 사용" +"slot layers without a matching slot property were imported as frames" = "일치하는 슬롯 속성이 없는 슬롯 레이어를 프레임으로 가져왔습니다." +"yesterday" = "어제" +"{month} {day}, {year}" = "{year}년 {month} {day}일" +"{n} text layers" = { other = "텍스트 레이어 {n}개" } +"{percent}%" = "{percent}%" [commands] "align.bottom" = "아래쪽 정렬" @@ -2199,6 +2315,24 @@ status = "draft — needs native review" "widget.list" = "위젯 목록" "window.focus" = "창을 맨 앞으로 가져오기" "window.new" = "새 창" +"arrange.reorder" = "선택 항목 순서 변경" +"component.hideWhenPublishing" = "게시할 때 숨기기" +"object.convertToGroup" = "그룹으로 변환" +"object.convertToFrame" = "프레임으로 변환" +"object.convertToSection" = "섹션으로 변환" +"text.rescanFonts" = "설치된 글꼴 다시 검색" +"text.loadFont" = "글꼴 파일 불러오기" +"view.outlinesHiddenLayers" = "윤곽선에 숨겨진 레이어 포함" +"view.outlinesObjectBounds" = "윤곽선에 개체 경계 포함" +"view.panelWidths" = "패널 너비 초기화" +"pref.uiScale" = "인터페이스 크기" +"help.gettingStarted" = "시작하기" +"style.bind" = "스타일에 변수 연결" +"component.playground" = "컴포넌트 실험실" +"text.missingFonts" = "누락된 글꼴" +"text.refit" = "글꼴에 맞게 텍스트 조정" +"template.list" = "템플릿 목록" +"file.newFromTemplate" = "템플릿에서 새로 만들기" [menu] "bool.exclude" = "제외" @@ -2230,3 +2364,10 @@ status = "draft — needs native review" "view.themeDark" = "다크" "view.themeLight" = "라이트" "view.themeSystem" = "시스템 따르기" +"file.newFromTemplate" = "템플릿에서 새로 만들기…" +"view.outlinesHiddenLayers" = "숨겨진 레이어 포함" +"view.outlinesObjectBounds" = "개체 경계 포함" +"view.panelWidths" = "패널 너비 초기화" +"text.missingFonts" = "누락된 글꼴…" +"component.hideWhenPublishing" = "게시할 때 숨기기" +"help.gettingStarted" = "시작하기" diff --git a/docs/korean-translation-validation.md b/docs/korean-translation-validation.md new file mode 100644 index 00000000..ad5cdcec --- /dev/null +++ b/docs/korean-translation-validation.md @@ -0,0 +1,25 @@ +# Korean translation validation + +Catalog additions against `d9c924c3ba7114dff9141c667aec56d3764f3af9`: **141** entries. Existing Korean entries are unchanged. The provided translation data was checked against the current Rust sources; candidate keys with no matching production literal were excluded. No new translations were generated. + +## Static checks + +- Catalog structure, unique keys, original key preservation, named placeholders, trailing ellipses, and explicit shortcut tokens checked with Python 3.14.5. +- FigCraft TOML was parsed with the standard TOML parser; Korean plural entries keep only `other`. +- New entries have source-location evidence in the local audit; dynamic labels use the existing translation paths. +- `git diff --check` passed. + +## Checks not run + +Cargo, rustc, and rustfmt are unavailable in this environment. Rust installation is awaiting user approval; no tools were installed. The following checks have **not** been run: + +- `cargo test -p figcraft-ui-egui` +- `cargo fmt --all --check` +- `cargo clippy -p figcraft-ui-egui --all-targets -- -D warnings` +- `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm`, and the full `cargo xtask ci` gate +- Native and web UI rendering, layout, runtime language switching, and font/glyph coverage + +## Candidate filtering + +- Excluded 0 supplied candidate keys without a matching production source literal. +- Excluded 0 supplied keys already proposed in open Korean translation PRs. From 9cbe31221a81cf058ff72e1e1a928a5cfee2fe3b Mon Sep 17 00:00:00 2001 From: AKY <85772856+arksky1@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:56:38 +0900 Subject: [PATCH 02/15] Record Korean translation coverage checks Use only root-supplied Korean translations; overlapping unmerged PRs are not dependencies. Preserve existing main translations. Static catalog, placeholder and source-lookup checks passed. Rust and native/web UI tests were not run because Rust installation remains awaiting approval. --- docs/korean-translation-validation.md | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/korean-translation-validation.md b/docs/korean-translation-validation.md index ad5cdcec..8b679261 100644 --- a/docs/korean-translation-validation.md +++ b/docs/korean-translation-validation.md @@ -1,25 +1,30 @@ # Korean translation validation -Catalog additions against `d9c924c3ba7114dff9141c667aec56d3764f3af9`: **141** entries. Existing Korean entries are unchanged. The provided translation data was checked against the current Rust sources; candidate keys with no matching production literal were excluded. No new translations were generated. +Catalog additions against `d9c924c3ba7114dff9141c667aec56d3764f3af9`: **141** entries. Every existing Korean entry is preserved. Only translations supplied by the root task were used. + +## Independent coverage + +- Restored 0 root-supplied keys previously omitted because an unmerged third-party PR also proposed them. These keys are included in this branch; no unmerged PR is required to supply them. +- The third-party PR patches were used to identify overlap, not as translation sources. +- The correction ZIP contains 153 direct-lookup mappings. All 153 keys and root-provided values were verified against their current source locations. +- Correction ZIP SHA256: `a97cb9c22e110d177ca7a5f0a70db976aee34056ae030fca2c3d9a0e884f755f`. ## Static checks -- Catalog structure, unique keys, original key preservation, named placeholders, trailing ellipses, and explicit shortcut tokens checked with Python 3.14.5. -- FigCraft TOML was parsed with the standard TOML parser; Korean plural entries keep only `other`. -- New entries have source-location evidence in the local audit; dynamic labels use the existing translation paths. +- Catalog syntax/table shape, unique keys, source keys, named placeholders, trailing ellipses, explicit shortcut tokens, and preservation of all existing Korean entries passed. +- Tokenized source audit: 1449 unique direct translation lookup keys; 0 word-bearing keys still missing from this catalog. +- Recorded 153 dynamic lookup sites for source inspection. Variable document text, file names, and machine identifiers retain their existing behavior. +- Finite dynamic checks passed: PhotoCraft menu catalog (632 keys), VectorCraft render-site table (21 keys), FigCraft English UI (1,747 keys), required command reference (574 keys), and menu reference (31 keys). All accepted root-supplied context/id/plural keys resolve. - `git diff --check` passed. ## Checks not run -Cargo, rustc, and rustfmt are unavailable in this environment. Rust installation is awaiting user approval; no tools were installed. The following checks have **not** been run: +Cargo, rustc, and rustfmt are unavailable. Rust installation remains awaiting user approval; no tools were installed. The following were not run: - `cargo test -p figcraft-ui-egui` - `cargo fmt --all --check` - `cargo clippy -p figcraft-ui-egui --all-targets -- -D warnings` -- `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm`, and the full `cargo xtask ci` gate -- Native and web UI rendering, layout, runtime language switching, and font/glyph coverage +- `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm`, and `cargo xtask ci` +- Native/web UI rendering, layout, runtime switching, and font/glyph coverage -## Candidate filtering - -- Excluded 0 supplied candidate keys without a matching production source literal. -- Excluded 0 supplied keys already proposed in open Korean translation PRs. +Static checks do not replace runtime verification or prove coverage of arbitrary runtime-generated messages. From ae35d1d794f5818efa3485b4216e8edbde1ca895 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:10:52 +0700 Subject: [PATCH 03/15] #966: drop the translation-validation notes (not part of the change) --- docs/korean-translation-validation.md | 30 --------------------------- 1 file changed, 30 deletions(-) delete mode 100644 docs/korean-translation-validation.md diff --git a/docs/korean-translation-validation.md b/docs/korean-translation-validation.md deleted file mode 100644 index 8b679261..00000000 --- a/docs/korean-translation-validation.md +++ /dev/null @@ -1,30 +0,0 @@ -# Korean translation validation - -Catalog additions against `d9c924c3ba7114dff9141c667aec56d3764f3af9`: **141** entries. Every existing Korean entry is preserved. Only translations supplied by the root task were used. - -## Independent coverage - -- Restored 0 root-supplied keys previously omitted because an unmerged third-party PR also proposed them. These keys are included in this branch; no unmerged PR is required to supply them. -- The third-party PR patches were used to identify overlap, not as translation sources. -- The correction ZIP contains 153 direct-lookup mappings. All 153 keys and root-provided values were verified against their current source locations. -- Correction ZIP SHA256: `a97cb9c22e110d177ca7a5f0a70db976aee34056ae030fca2c3d9a0e884f755f`. - -## Static checks - -- Catalog syntax/table shape, unique keys, source keys, named placeholders, trailing ellipses, explicit shortcut tokens, and preservation of all existing Korean entries passed. -- Tokenized source audit: 1449 unique direct translation lookup keys; 0 word-bearing keys still missing from this catalog. -- Recorded 153 dynamic lookup sites for source inspection. Variable document text, file names, and machine identifiers retain their existing behavior. -- Finite dynamic checks passed: PhotoCraft menu catalog (632 keys), VectorCraft render-site table (21 keys), FigCraft English UI (1,747 keys), required command reference (574 keys), and menu reference (31 keys). All accepted root-supplied context/id/plural keys resolve. -- `git diff --check` passed. - -## Checks not run - -Cargo, rustc, and rustfmt are unavailable. Rust installation remains awaiting user approval; no tools were installed. The following were not run: - -- `cargo test -p figcraft-ui-egui` -- `cargo fmt --all --check` -- `cargo clippy -p figcraft-ui-egui --all-targets -- -D warnings` -- `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm`, and `cargo xtask ci` -- Native/web UI rendering, layout, runtime switching, and font/glyph coverage - -Static checks do not replace runtime verification or prove coverage of arbitrary runtime-generated messages. From 4667a7dbd261fffd262bf61141a0d8675d3d1706 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 10:10:53 +0700 Subject: [PATCH 04/15] #966: drop the five [menu] wordings that read like their command ([commands] covers them) --- crates/ui-egui/locales/ko.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index d923932b..bbce75eb 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -2417,10 +2417,5 @@ status = "draft — needs native review" "view.themeDark" = "다크" "view.themeLight" = "라이트" "view.themeSystem" = "시스템 따르기" -"file.newFromTemplate" = "템플릿에서 새로 만들기…" "view.outlinesHiddenLayers" = "숨겨진 레이어 포함" "view.outlinesObjectBounds" = "개체 경계 포함" -"view.panelWidths" = "패널 너비 초기화" -"text.missingFonts" = "누락된 글꼴…" -"component.hideWhenPublishing" = "게시할 때 숨기기" -"help.gettingStarted" = "시작하기" From f1b41ffeaa00a6bb27ad2b9cf26f92d9e27f399c Mon Sep 17 00:00:00 2001 From: Denis Gomes <6731020+denisg0mes@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:45:20 -0400 Subject: [PATCH 05/15] M7: find fonts that macOS font managers activate outside the font folders Font managers such as RightFont, FontBase and Typeface keep fonts in their own library folders and activate them through Core Text, without copying them into /Library/Fonts or ~/Library/Fonts. Every other Mac app lists these fonts; FigCraft walks only the font folders, so it didn't, and a file using one of them drew in a fallback font. On macOS the scan now also asks Core Text for the file of every available font and indexes the files that lie outside the walked folders. All three scans (the first, the on-demand one and text.rescanFonts) share this, so a font activated while the app runs is found by a rescan like a newly installed one. Core Text needs unsafe FFI, which the workspace forbids, so the query goes through the safe wrappers of the core-text crate, pinned at =20.1.0. That version builds on the core-foundation 0.9, core-graphics 0.23 and foreign-types 0.5 the lockfile already has: core-text is the only new package. The call sits inside catch_unwind, so anything unexpected from Core Text leaves the scan with the font folders alone. Other platforms are unchanged. On a Mac with RightFont, 15 families from its library are now listed (Geist, JetBrains Mono, Clash Display and others) that weren't before. A full font listing went from 0.06 s to 0.20 s on that machine. Tests: the filter that keeps files outside the font folders once, indexing a font file found outside them, and (macOS only) that Core Text lists the system's font files. cargo test --workspace, cargo clippy -p figcraft-text --all-targets -D warnings and cargo fmt --check pass on macOS 15. CI builds on Linux only, so the macOS path is checked locally. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 5699e7abe6e5994e526051774ec92aadddb0550c) --- Cargo.lock | 13 +++++ crates/text/Cargo.toml | 8 +++ crates/text/src/fontdb.rs | 94 ++++++++++++++++++++++----------- crates/text/src/fontdb_tests.rs | 49 +++++++++++++++++ docs/cli.md | 6 +++ 5 files changed, 138 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 40a16fe7..2261e23c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -871,6 +871,18 @@ dependencies = [ "libc", ] +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types", + "libc", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -1782,6 +1794,7 @@ dependencies = [ name = "figcraft-text" version = "0.11.2" dependencies = [ + "core-text", "figcraft-doc", "figcraft-geom", "harfrust 0.13.3", diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index 84a80858..d1b983af 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -18,3 +18,11 @@ png = { workspace = true } unicode-bidi = { workspace = true } unicode-segmentation = { workspace = true } log = { workspace = true } + +# Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font +# folder are known only to Core Text. Its safe wrappers list every available font's file (Core Text +# needs unsafe FFI, which the workspace forbids). Its dependencies (core-foundation 0.9, +# core-graphics 0.23, foreign-types 0.5, libc) are already in the lockfile. Pinned exactly: review +# the diff before bumping. +[target.'cfg(target_os = "macos")'.dependencies] +core-text = { version = "=20.1.0", default-features = false } diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 32552837..d1f115da 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -817,9 +817,7 @@ impl FontDb { } self.system_scanned = true; let mut found = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); self.merge_scan(found); } @@ -924,29 +922,45 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap MAX_FONT_FILE { - continue; - } - let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue }; - for (index, family, style, postscript) in faces_in(&bytes) { - out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { - path: path.clone(), - index, - style, - postscript, - }); + index_font_file(&path, &meta, out); + } +} + +fn index_font_file(path: &std::path::Path, meta: &std::fs::Metadata, out: &mut BTreeMap)>) { + // Font-like names can also belong to pipes or devices; opening a pipe can block. + // `meta` follows symlinks, so links to regular font files remain supported. + if !meta.is_file() { + return; + } + let ext = path.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase).unwrap_or_default(); + if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") && !(path.extension().is_none() && starts_like_a_font(path)) { + return; + } + // Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the + // system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed + // buffer, so only those parts are ever touched. + if meta.len() > MAX_FONT_FILE { + return; + } + let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(path, l)) else { return }; + for (index, family, style, postscript) in faces_in(&bytes) { + out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { + path: path.to_path_buf(), + index, + style, + postscript, + }); + } +} + +fn scan_installed_fonts(out: &mut BTreeMap)>) { + let dirs = system_font_dirs(); + for dir in &dirs { + scan_dir(dir, 0, out); + } + for path in files_outside_dirs(font_manager_files(), &dirs) { + if let Ok(meta) = std::fs::metadata(&path) { + index_font_file(&path, &meta, out); } } } @@ -959,6 +973,26 @@ fn starts_like_a_font(path: &std::path::Path) -> bool { std::fs::File::open(path).and_then(|mut f| f.read_exact(&mut magic)).is_ok() && matches!(&magic, &[0, 1, 0, 0] | b"OTTO" | b"true" | b"ttcf") } +fn files_outside_dirs(files: Vec, dirs: &[std::path::PathBuf]) -> std::collections::BTreeSet { + files.into_iter().filter(|file| file.is_absolute() && !dirs.iter().any(|dir| file.starts_with(dir))).collect() +} + +#[cfg(target_os = "macos")] +fn font_manager_files() -> Vec { + std::panic::catch_unwind(|| { + core_text::font_collection::create_for_all_families() + .get_descriptors() + .map(|descriptors| descriptors.iter().filter_map(|descriptor| descriptor.font_path()).collect()) + .unwrap_or_default() + }) + .unwrap_or_default() +} + +#[cfg(not(target_os = "macos"))] +fn font_manager_files() -> Vec { + Vec::new() +} + /// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB). pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024; @@ -1082,9 +1116,7 @@ pub fn scan_system_fonts() { if !system_fonts_opted_out() { SCANS.fetch_add(1, Ordering::Relaxed); let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); let mut w = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner); w.system_scanned = true; w.merge_scan(found); @@ -1108,9 +1140,7 @@ pub fn rescan_system_fonts() -> Rescan { return Rescan { changed: added.clone(), added }; } let mut found: BTreeMap)> = BTreeMap::new(); - for dir in system_font_dirs() { - scan_dir(&dir, 0, &mut found); - } + scan_installed_fonts(&mut found); let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); if !rescan.changed.is_empty() { crate::clear_layout_cache(); diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 72d5eafd..06b260b2 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -440,3 +440,52 @@ fn every_platform_has_a_thai_font_in_the_fallbacks() { assert!(at(thai).is_some_and(|i| i > latin), "{thai}"); } } + +#[test] +fn font_manager_files_outside_the_font_folders_are_indexed_once() { + let folders = [std::path::PathBuf::from("/Library/Fonts"), std::path::PathBuf::from("/Users/a/Library/Fonts")]; + let library = std::path::PathBuf::from("/Users/a/Fonts.rightfontlibrary/fonts/I/Inter/InterVariable.ttf"); + let files = vec![ + std::path::PathBuf::from("/Library/Fonts/Arial.ttf"), + std::path::PathBuf::from("/Users/a/Library/Fonts/Sub/Brand.otf"), + library.clone(), + library.clone(), + std::path::PathBuf::from("relative/Font.ttf"), + std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), + ]; + let outside: Vec<_> = files_outside_dirs(files, &folders).into_iter().collect(); + assert_eq!(outside, [std::path::PathBuf::from("/Library/FontsElsewhere/Mono.ttf"), library]); +} + +#[test] +fn a_font_file_outside_the_font_folders_is_indexed_from_its_path() { + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let installed = system_font_dirs() + .into_iter() + .find_map(|d| std::fs::read_dir(d).into_iter().flatten().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "ttf"))); + let Some(font) = installed else { return }; + let tmp = TempDir(std::env::temp_dir().join(format!("figcraft-font-manager-{}", std::process::id()))); + std::fs::create_dir_all(&tmp.0).unwrap(); + let copy = tmp.0.join("Activated.ttf"); + std::fs::copy(&font, ©).unwrap(); + let mut pending = BTreeMap::new(); + index_font_file(©, &std::fs::metadata(©).unwrap(), &mut pending); + let paths: Vec<_> = pending.values().flat_map(|(_, p)| p.iter().map(|p| p.path.clone())).collect(); + assert!(!paths.is_empty() && paths.iter().all(|p| *p == copy), "{paths:?}"); + let mut none = BTreeMap::new(); + index_font_file(&tmp.0, &std::fs::metadata(&tmp.0).unwrap(), &mut none); + assert!(none.is_empty()); +} + +#[cfg(target_os = "macos")] +#[test] +fn core_text_lists_the_installed_font_files() { + let files = font_manager_files(); + assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len()); + assert!(files.iter().all(|f| f.is_absolute())); +} diff --git a/docs/cli.md b/docs/cli.md index 1e1e1967..68e37823 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -25,6 +25,12 @@ On Linux and other Unix systems except macOS, user fonts are read from `$XDG_DATA_HOME/fonts`, or `$HOME/.local/share/fonts` when `XDG_DATA_HOME` is unset, empty or relative. The legacy `$HOME/.fonts` directory is also scanned. +On macOS the font folders (`/System/Library/Fonts`, `/Library/Fonts`, `~/Library/Fonts` and the +system's downloadable fonts) are read, and so is every other font file Core Text lists as +available: fonts a font manager such as RightFont, FontBase or Typeface activates from its own +library, without copying them into a font folder. Fonts activated while the app runs are found by +`text.rescanFonts`, like newly installed ones. + On macOS, fonts activated through Adobe Fonts are read where Creative Cloud keeps them (`~/Library/Application Support/Adobe/CoreSync/plugins/livetype`), so they're available while they're active there. Files without an extension are read when they start like a font file. From 5e32057bdde3b071ea736cfcb9de22efc559f0c0 Mon Sep 17 00:00:00 2001 From: Denis Gomes <6731020+denisg0mes@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:53:16 -0400 Subject: [PATCH 06/15] M7: font manager scan: drain Core Text's autoreleased objects; core-text 22 Review on #1309 pointed out that core-text 20.1's get_descriptors retains the array it gets under the create rule a second time, so every scan kept one array of font descriptors alive. Checking that turned up a larger leak: Core Text autoreleases objects while it builds the collection, and the scan runs where no autorelease pool is active (the app's background scan thread, the CLI, the MCP server), so nothing ever drained them. Measured with `figcraft-cli run` of N `text.rescanFonts` steps on macOS 15 (peak memory footprint): | | 10 rescans | 200 rescans | |---|---|---| | main, without this PR | 221 MB | 220 MB | | this PR before the fix | 228 MB | 796 MB | | with the fix | 226 MB | 225 MB | A standalone Swift loop over the same Core Text calls grows from 9 MB to 759 MB over 200 scans without a pool and stays at 20 MB inside one, so the pool is what matters; the core-text bump removes the over-retain on top. - The query runs inside objc2::rc::autoreleasepool. objc2 0.6 is already in the lockfile (winit's), and the function is safe, so the workspace's unsafe_code = "forbid" still holds. - core-text goes to =22.0.0, the first release that takes ownership of the array correctly. It brings core-foundation 0.10, core-graphics 0.25 and core-graphics-types 0.2 beside the older versions other crates use. 22.0 moved framework linking behind a `link` feature that is on by default, so with default features off it is named explicitly. A rescan takes about 0.2 s instead of 0.08 s on that Mac (266 more font files are indexed); it only runs when asked for. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 7f6a3a4e46922d737b8053c7cd5968a02a558ae3) --- Cargo.lock | 40 +++++++++++++++++++++++++++++++-------- crates/text/Cargo.toml | 10 ++++++---- crates/text/src/fontdb.rs | 10 ++++++---- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2261e23c..24536ff9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -855,7 +855,20 @@ checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "core-graphics-types", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", "foreign-types", "libc", ] @@ -872,17 +885,27 @@ dependencies = [ ] [[package]] -name = "core-text" -version = "20.1.0" +name = "core-graphics-types" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "core-foundation", - "core-graphics", - "foreign-types", + "bitflags 2.13.2", + "core-foundation 0.10.1", "libc", ] +[[package]] +name = "core-text" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333dab512ce710ca2d08574c373d246dbeac8b22769e47da4c0e72730ce442b7" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "foreign-types", +] + [[package]] name = "core_detect" version = "1.0.0" @@ -1799,6 +1822,7 @@ dependencies = [ "figcraft-geom", "harfrust 0.13.3", "log", + "objc2 0.6.5", "png", "skrifa 0.47.0", "unicode-bidi", @@ -5995,7 +6019,7 @@ dependencies = [ "cfg_aliases", "concurrent-queue", "core-foundation 0.9.4", - "core-graphics", + "core-graphics 0.23.2", "cursor-icon", "dpi", "js-sys", diff --git a/crates/text/Cargo.toml b/crates/text/Cargo.toml index d1b983af..5322ca58 100644 --- a/crates/text/Cargo.toml +++ b/crates/text/Cargo.toml @@ -21,8 +21,10 @@ log = { workspace = true } # Fonts that font managers (RightFont, FontBase, Typeface) activate without copying them into a font # folder are known only to Core Text. Its safe wrappers list every available font's file (Core Text -# needs unsafe FFI, which the workspace forbids). Its dependencies (core-foundation 0.9, -# core-graphics 0.23, foreign-types 0.5, libc) are already in the lockfile. Pinned exactly: review -# the diff before bumping. +# needs unsafe FFI, which the workspace forbids). 22.0 is the first release whose +# `get_descriptors` takes ownership of the array correctly (20.x and 21.x leak it on every scan); +# it brings core-foundation 0.10, core-graphics 0.25 and core-graphics-types 0.2 beside the older +# versions other crates use. Pinned exactly: review the diff before bumping. [target.'cfg(target_os = "macos")'.dependencies] -core-text = { version = "=20.1.0", default-features = false } +core-text = { version = "=22.0.0", default-features = false, features = ["link"] } +objc2 = "0.6" diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index d1f115da..ced18c81 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -980,10 +980,12 @@ fn files_outside_dirs(files: Vec, dirs: &[std::path::PathBuf #[cfg(target_os = "macos")] fn font_manager_files() -> Vec { std::panic::catch_unwind(|| { - core_text::font_collection::create_for_all_families() - .get_descriptors() - .map(|descriptors| descriptors.iter().filter_map(|descriptor| descriptor.font_path()).collect()) - .unwrap_or_default() + objc2::rc::autoreleasepool(|_| { + core_text::font_collection::create_for_all_families() + .get_descriptors() + .map(|descriptors| descriptors.iter().filter_map(|descriptor| descriptor.font_path()).collect()) + .unwrap_or_default() + }) }) .unwrap_or_default() } From eb73f021c626166b54d4b24263f269a4fc91c680 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 26 Oct 2026 05:37:27 +0100 Subject: [PATCH 07/15] Keep 8-bit alpha when printing colours with Color::to_css Found while testing crates/color/src/lib.rs:198 (Color::to_css) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 9 ++++++++- crates/color/src/tests.rs | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 7ce1df5e..0b7ae704 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -195,7 +195,14 @@ impl Color { /// CSS `rgba(...)` / hex text for copy-as-CSS. pub fn to_css(self) -> String { let [r, g, b, _] = self.to_rgba8(); - if self.a >= 0.999 { self.to_css_hex() } else { format!("rgba({r}, {g}, {b}, {:.2})", self.a) } + if self.a >= 0.999 { + return self.to_css_hex(); + } + // Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours). + let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8; + let short = format!("{:.2}", self.a); + let alpha = if short.parse::().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) }; + format!("rgba({r}, {g}, {b}, {alpha})") } /// Linear interpolation in sRGB (as gradients render). diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 6fedad83..2b628b71 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -133,3 +133,11 @@ fn malformed_alpha_or_extra_args_rejected() { assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0); assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5); } + +#[test] +fn to_css_round_trips_every_alpha() { + for a in 0..=255u8 { + let c = Color::from_rgba8(10, 20, 30, a); + assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8(), c.to_rgba8(), "alpha {a}: {}", c.to_css()); + } +} From 4fad29a66e2521789244344cee1332b7a2409bb2 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 26 Oct 2026 12:28:59 +0700 Subject: [PATCH 08/15] M12: board timer and voting sessions (#1361) * M12: board timer and voting A countdown everyone on a board sees (board.timer, board.timerState), synced as its end time or the time left while paused, and voting sessions (board.vote, board.voteResults): votes per person, on everything, stickies or the selection; clicks vote, your badge takes a vote back; your own votes show while it runs, everyone's with a ranked list once it ends. Timer and vote sync as document properties and ballots as one entry per voter; neither is an undo step. A pill at the top right of the board, new Timer, Pause and Vote icons, labels and strings in every catalog, docs. * M12: board timer and voting on egui 0.37 Id::new is deprecated in egui 0.37: the timer and voting area and its memory ids use Id::unique. * M12: board.vote's start and ballot changes in functions of their own (Sonar) --- ROADMAP.md | 6 +- crates/collab/src/change.rs | 11 +- crates/collab/src/docprops.rs | 13 +- crates/collab/src/merge.rs | 1 + crates/doc/src/facilitation.rs | 111 ++++++++ crates/doc/src/lib.rs | 14 + crates/engine/src/board.rs | 2 +- crates/engine/src/branches.rs | 2 +- crates/engine/src/cmd/facilitate.rs | 373 +++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/facilitate_tests.rs | 324 +++++++++++++++++++++ crates/engine/src/lib.rs | 8 +- crates/engine/src/tools.rs | 4 + crates/ui-egui/locales/bn.toml | 23 ++ crates/ui-egui/locales/cs.toml | 23 ++ crates/ui-egui/locales/de.toml | 23 ++ crates/ui-egui/locales/en.toml | 19 ++ crates/ui-egui/locales/es-419.toml | 1 + crates/ui-egui/locales/es.toml | 23 ++ crates/ui-egui/locales/fi.toml | 23 ++ crates/ui-egui/locales/fr.toml | 23 ++ crates/ui-egui/locales/id.toml | 23 ++ crates/ui-egui/locales/ja.toml | 23 ++ crates/ui-egui/locales/ka.toml | 23 ++ crates/ui-egui/locales/ko.toml | 23 ++ crates/ui-egui/locales/nl.toml | 23 ++ crates/ui-egui/locales/pl.toml | 23 ++ crates/ui-egui/locales/pt-BR.toml | 23 ++ crates/ui-egui/locales/ru.toml | 23 ++ crates/ui-egui/locales/sv.toml | 23 ++ crates/ui-egui/locales/tr.toml | 23 ++ crates/ui-egui/locales/zh-Hans.toml | 23 ++ crates/ui-egui/src/board_facilitate.rs | 257 +++++++++++++++++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/icons.rs | 21 ++ crates/ui-egui/src/lib.rs | 1 + docs/boards-parity.md | 17 +- docs/collab-protocol.md | 6 +- docs/gaps.md | 22 +- docs/mcp.md | 34 +++ docs/roadmap.md | 5 +- docs/target-app-parity.md | 5 +- 42 files changed, 1621 insertions(+), 31 deletions(-) create mode 100644 crates/doc/src/facilitation.rs create mode 100644 crates/engine/src/cmd/facilitate.rs create mode 100644 crates/engine/src/facilitate_tests.rs create mode 100644 crates/ui-egui/src/board_facilitate.rs diff --git a/ROADMAP.md b/ROADMAP.md index 40ffa470..cf505389 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-25 · **Change:** minor (boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -93,7 +93,7 @@ native speakers' reviews of the nine language drafts need people. | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | -| Boards (whiteboarding; not in the weighted numbers) | ~37% | ~21% | 28–56 | [boards-parity](docs/boards-parity.md) | +| Boards (whiteboarding; not in the weighted numbers) | ~42% | ~25% | 24–50 | [boards-parity](docs/boards-parity.md) | ## Languages @@ -137,6 +137,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)). - **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged. - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. +- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. @@ -344,6 +345,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | +| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index b273ba34..7977f83a 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -223,6 +223,10 @@ pub enum DocProp { VariableOrder, /// A design file or a board. Kind, + /// A board's countdown (`null`: none). + Timer, + /// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s. + Vote, } /// A document-level entry, by id. @@ -242,6 +246,8 @@ pub enum Entry { DevSnapshot(NodeId), /// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64). Image(String), + /// A voter's ballot in a board's vote (value: the layer ids voted on). + Ballot(String), } /// One synced value a change writes: a node property, a document-level property or a @@ -315,7 +321,7 @@ impl Entry { Entry::Variable(id) => Some(id_space(id, VARIABLE_ID_PREFIX)), Entry::Collection(id) => Some(id_space(id, COLLECTION_ID_PREFIX)), Entry::Comment(id) => Some(space_of(*id)), - Entry::DevSnapshot(_) | Entry::Image(_) => None, + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None, } } @@ -328,6 +334,7 @@ impl Entry { Entry::Variable(id) => format!("variable {id}"), Entry::DevSnapshot(id) => format!("ready-for-dev snapshot of {id}"), Entry::Image(hash) => format!("image {hash}"), + Entry::Ballot(voter) => format!("ballot of {voter}"), } } @@ -338,7 +345,7 @@ impl Entry { Entry::Comment(_) => Some(Family::Comments), Entry::Collection(_) => Some(Family::Collections), Entry::Variable(_) => Some(Family::Variables), - Entry::DevSnapshot(_) | Entry::Image(_) => None, + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None, } } } diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index e4ff32c2..24a604ff 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -211,6 +211,8 @@ doc_scalars! { AnnotationCategories => annotation_categories, Libraries => libraries, Kind => kind, + Timer => timer, + Vote => vote, } /// Keyed maps (no order): changed and added entries are put, missing ones removed. @@ -250,7 +252,8 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec) -> Result< |k| Entry::Image(k.clone()), image_json, out, - ) + )?; + diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out) } /// Apply a document-level change. Returns whether it can change which pages are listed (the @@ -286,6 +289,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result let img = image_of(value)?; doc.images.insert(k.clone(), img); } + Entry::Ballot(k) => { + doc.votes.insert(k.clone(), value.read("ballot")?); + } }, Change::Remove { entry } => match entry { Entry::Style(k) => STYLES.remove(doc, k), @@ -298,6 +304,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result Entry::Image(k) => { doc.images.remove(k); } + Entry::Ballot(k) => { + doc.votes.remove(k); + } }, _ => return Err(Error::Invalid("not a document-level change".into())), } @@ -334,6 +343,7 @@ pub(crate) fn entry_value(doc: &Document, entry: &Entry) -> Result, Entry::Variable(k) => doc.variables.variables.iter().find(|v| v.id == *k).map(Json::of), Entry::DevSnapshot(k) => doc.dev_snapshots.get(k).map(|s| Json::of(s.as_ref())), Entry::Image(k) => doc.images.get(k).map(image_json), + Entry::Ballot(k) => doc.votes.get(k).map(Json::of), }; value.transpose() } @@ -347,6 +357,7 @@ pub(crate) fn has(doc: &Document, entry: &Entry) -> bool { Entry::Variable(k) => doc.variables.variables.iter().any(|v| v.id == *k), Entry::DevSnapshot(k) => doc.dev_snapshots.contains_key(k), Entry::Image(k) => doc.images.contains_key(k), + Entry::Ballot(k) => doc.votes.contains_key(k), } } diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index acd40397..9c21a56a 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -92,6 +92,7 @@ impl fmt::Display for Key { Entry::Variable(k) => write!(f, "variable/{k}"), Entry::DevSnapshot(k) => write!(f, "devSnapshot/{k}"), Entry::Image(k) => write!(f, "image/{k}"), + Entry::Ballot(k) => write!(f, "ballot/{k}"), }, } } diff --git a/crates/doc/src/facilitation.rs b/crates/doc/src/facilitation.rs new file mode 100644 index 00000000..fd3ae1d2 --- /dev/null +++ b/crates/doc/src/facilitation.rs @@ -0,0 +1,111 @@ +//! A board's facilitation state, shared by everyone on it: the countdown timer and the vote. +//! +//! The timer keeps when it ends (or, paused, how long is left), never the ticks: each client works +//! out the time left from its own clock. A vote's ballots are kept per voter, so two people voting +//! at once change different entries. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::{BoardItem, Document, NodeId}; + +/// The longest a timer runs (24 hours, in milliseconds). +pub const TIMER_MAX_MS: u64 = 24 * 60 * 60 * 1000; +/// The most votes one person gets in a vote. +pub const VOTES_MAX: u32 = 99; + +/// A board's countdown. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "camelCase")] +pub enum BoardTimer { + /// Counting down to `ends` (milliseconds since the Unix epoch). + Running { ends: u64 }, + /// Stopped with `left` milliseconds to go. + Paused { left: u64 }, +} + +impl BoardTimer { + /// Milliseconds left at `now` (ms since the Unix epoch); 0 once it has run out. + pub fn left(&self, now: u64) -> u64 { + match *self { + BoardTimer::Running { ends } => ends.saturating_sub(now).min(TIMER_MAX_MS), + BoardTimer::Paused { left } => left.min(TIMER_MAX_MS), + } + } +} + +/// What a vote is on. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum VoteScope { + /// Any object on the board. + #[default] + All, + /// Sticky notes only. + Stickies, + /// The objects that were selected when it started. + Only { ids: Vec }, +} + +impl VoteScope { + /// The object a vote at layer `leaf` goes to: the nearest layer from it up that the vote is + /// on (`All`: `whole`, the object a click there picks). + pub fn target(&self, doc: &Document, leaf: NodeId, whole: NodeId) -> Option { + if matches!(self, VoteScope::All) { + return self.takes(doc, whole).then_some(whole); + } + std::iter::once(leaf).chain(doc.ancestors(leaf)).find(|id| self.takes(doc, *id)) + } + + /// Whether object `id` takes votes. + pub fn takes(&self, doc: &Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; + if doc.pages.contains(&id) || n.parent.is_none() { + return false; + } + match self { + VoteScope::All => true, + VoteScope::Stickies => matches!(n.board, Some(BoardItem::Sticky { .. })), + VoteScope::Only { ids } => ids.contains(&id), + } + } +} + +/// A vote: how many votes each person has, what they go on, and whether it has ended (then +/// everyone's votes show). The ballots are [`Document::votes`]. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Vote { + pub per_person: u32, + #[serde(default)] + pub scope: VoteScope, + #[serde(default)] + pub ended: bool, + /// Who started it. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub by: String, +} + +/// Every voter's ballot: the objects they put their votes on (once per vote). +pub type Ballots = BTreeMap>; + +impl Vote { + /// The votes of `ballot` that count: on objects that are still there, up to the cap. + pub fn counted<'a>(&self, doc: &'a Document, ballot: &'a [NodeId]) -> impl Iterator + 'a { + ballot.iter().copied().filter(|id| doc.node(*id).is_some()).take(self.per_person.min(VOTES_MAX) as usize) + } + + /// The votes each object got, most first (ties in layer id order). + pub fn tally(&self, doc: &Document, ballots: &Ballots) -> Vec<(NodeId, u32)> { + let mut counts: BTreeMap = BTreeMap::new(); + for ballot in ballots.values() { + for id in self.counted(doc, ballot) { + *counts.entry(id).or_default() += 1; + } + } + let mut out: Vec<(NodeId, u32)> = counts.into_iter().collect(); + out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + out + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index d1baebf6..80eada04 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -11,6 +11,7 @@ pub mod board; pub mod colors; pub mod comments; +pub mod facilitation; pub mod geometry; pub mod handoff; pub mod ids; @@ -27,6 +28,7 @@ pub mod vector_regions; pub use board::*; pub use colors::*; pub use comments::*; +pub use facilitation::*; pub use figcraft_color as color; pub use figcraft_geom as geom; pub use handoff::*; @@ -208,6 +210,15 @@ pub struct Document { /// [`handoff`]). #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub dev_snapshots: BTreeMap>, + /// A board's countdown (see [`facilitation`]). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timer: Option, + /// A board's vote, running or ended. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vote: Option, + /// The vote's ballots, by voter. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub votes: Ballots, } impl Default for Document { @@ -247,6 +258,9 @@ impl Document { image_presets: Vec::new(), annotation_categories: None, dev_snapshots: BTreeMap::new(), + timer: None, + vote: None, + votes: BTreeMap::new(), } } diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs index 4dd76bbd..c56d52a1 100644 --- a/crates/engine/src/board.rs +++ b/crates/engine/src/board.rs @@ -240,7 +240,7 @@ fn line_shade(c: Color) -> Color { /// Your name on what you make: the name from Preferences, else the one a live session gave you /// (none for the default name outside a session). -fn author(s: &Session) -> String { +pub(crate) fn author(s: &Session) -> String { let name = crate::live::display_name(&s.prefs); if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } } diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 8bff0568..8fdea0fc 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -458,7 +458,7 @@ fn entry_label(plan: &Plan, entry: &Entry, value: Option<&Value>) -> Option<(&'s Entry::Variable(_) => "variable", Entry::Collection(_) => "variable collection", Entry::Comment(_) => "comment", - Entry::DevSnapshot(_) | Entry::Image(_) => return None, + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => return None, }; let base = plan.base().value(&Target::Entry(entry.clone())).ok().flatten(); let named = value.or(base.as_ref().map(|j| &j.0)).and_then(|v| v.get("name").or_else(|| v.get("text"))).and_then(Value::as_str); diff --git a/crates/engine/src/cmd/facilitate.rs b/crates/engine/src/cmd/facilitate.rs new file mode 100644 index 00000000..9a1513a6 --- /dev/null +++ b/crates/engine/src/cmd/facilitate.rs @@ -0,0 +1,373 @@ +//! Running a session on a board: a countdown everyone sees and votes on objects. +//! +//! Both live in the document ([`figcraft_doc::facilitation`]) so they reach everyone in a live +//! session, and neither is an undo step: undoing an edit leaves the timer and the vote as they are. + +use std::sync::Arc; + +use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope}; +use figcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::board::{require, text_of}; +use crate::cmd::CommandSpec; +use crate::params::{id, num, req_string, req_targets, string}; +use crate::tools::PointerEvent; +use crate::{EngineError, Result, Session, bad, spec}; + +/// Minutes a timer starts with, and adds, when none are given. +const START_MINUTES: f64 = 5.0; +const ADD_MINUTES: f64 = 1.0; +/// Votes each person gets when none are given. +const VOTES: u32 = 3; +/// A vote badge's radius on screen (logical pixels). +pub const BADGE_PX: f64 = 11.0; +/// The longest a voter name is kept. +const MAX_VOTER: usize = 64; + +pub fn specs() -> Vec { + vec![ + spec!( + "board.timer", + "Timer", + "{action: start|pause|resume|add|stop, minutes? (start: default 5; add: default 1; up to 1440)} → the timer's state (as board.timerState) one countdown per board, the same for everyone in a live session", + false, + timer_cmd + ), + spec!( + "board.timerState", + "Timer state", + "{} → {state: off|running|paused|done, left (ms), text (m:ss), ends? (ms since 1970, while running)}", + false, + |s, _| { + require(s, "board.timerState")?; + Ok(timer_json(s.doc()?.timer, now_ms())) + } + ), + spec!( + "board.vote", + "Voting session", + "{action: start|cast|retract|end|clear, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends", + false, + vote_cmd + ), + spec!( + "board.voteResults", + "Voting results", + "{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank}] (once ended, most votes first)}", + false, + |s, p| { + require(s, "board.voteResults")?; + let who = voter(s, p); + Ok(vote_json(s.doc()?, &who)) + } + ), + ] +} + +/// Milliseconds since the Unix epoch (0 if the clock is before it). +pub fn now_ms() -> u64 { + web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX)) +} + +/// `left` milliseconds as a clock: "4:05", "1:02:03" (whole seconds, rounded up). +pub fn clock(left: u64) -> String { + let s = left.div_ceil(1000); + let (h, m, s) = (s / 3600, s / 60 % 60, s % 60); + if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") } +} + +/// The timer's state at `now`: off, running, paused or done (run out, until someone stops it). +pub fn timer_state(t: Option, now: u64) -> &'static str { + match t { + None => "off", + Some(BoardTimer::Paused { .. }) => "paused", + Some(t) if t.left(now) == 0 => "done", + Some(_) => "running", + } +} + +fn timer_json(t: Option, now: u64) -> Value { + let left = t.map_or(0, |t| t.left(now)); + let ends = match t { + Some(BoardTimer::Running { ends }) => Some(ends), + _ => None, + }; + json!({"state": timer_state(t, now), "left": left, "text": clock(left), "ends": ends}) +} + +/// `minutes` (or `default`) as milliseconds: more than 0, at most a day. +fn duration(cmd: &str, p: &Value, default: f64) -> Result { + let m = match p.get("minutes").filter(|v| !v.is_null()) { + Some(_) => num(p, "minutes").ok_or_else(|| bad(cmd, "`minutes` must be a number"))?, + None => default, + }; + let ms = (m * 60_000.0).round(); + if !(1000.0..=TIMER_MAX_MS as f64).contains(&ms) { + return Err(bad(cmd, "`minutes` must be from 1 second to 1440 minutes")); + } + Ok(ms as u64) +} + +/// The timer after `action` at `now` (`Ok(None)`: stopped). +pub fn timer_after(cmd: &str, t: Option, action: &str, p: &Value, now: u64) -> Result> { + let off = || EngineError::Disabled(cmd.into(), "there is no timer: start one".into()); + Ok(match action { + "start" => Some(BoardTimer::Running { ends: now.saturating_add(duration(cmd, p, START_MINUTES)?) }), + "stop" => None, + "pause" => match t { + Some(r @ BoardTimer::Running { .. }) if r.left(now) > 0 => Some(BoardTimer::Paused { left: r.left(now) }), + _ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't running".into())), + }, + "resume" => match t { + Some(BoardTimer::Paused { left }) => Some(BoardTimer::Running { ends: now.saturating_add(left) }), + _ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't paused".into())), + }, + "add" => { + let more = duration(cmd, p, ADD_MINUTES)?; + let left = t.ok_or_else(off)?.left(now).saturating_add(more).min(TIMER_MAX_MS); + match t { + Some(BoardTimer::Paused { .. }) => Some(BoardTimer::Paused { left }), + _ => Some(BoardTimer::Running { ends: now.saturating_add(left) }), + } + } + other => return Err(bad(cmd, format!("unknown action `{other}` (start|pause|resume|add|stop)"))), + }) +} + +fn timer_cmd(s: &mut Session, p: &Value) -> Result { + require(s, "board.timer")?; + let action = req_string("board.timer", p, "action")?; + let now = now_ms(); + let t = timer_after("board.timer", s.doc()?.timer, &action, p, now)?; + if s.doc()?.timer != t { + s.doc_mut()?.timer = t; + } + Ok(timer_json(t, now)) +} + +/// Who votes: `voter` from the params, else you as the live session knows you (your client id +/// there, so two people of the same name vote apart), else your name. +pub fn voter(s: &Session, p: &Value) -> String { + if let Some(v) = string(p, "voter").map(|v| v.trim().chars().filter(|c| !c.is_control()).take(MAX_VOTER).collect::()) + && !v.is_empty() + { + return v; + } + let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).and_then(|ds| s.live_of(ds.uid).info.as_ref()); + match live { + Some(info) => format!("peer {}", info.me), + None => crate::cmd::comment::user_name(s), + } +} + +fn running<'a>(cmd: &str, doc: &'a Document) -> Result<&'a Vote> { + match &doc.vote { + Some(v) if !v.ended => Ok(v), + _ => Err(EngineError::Disabled(cmd.into(), "no vote is running".into())), + } +} + +fn vote_cmd(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "board.vote"; + require(s, CMD)?; + let action = req_string(CMD, p, "action")?; + let who = voter(s, p); + match action.as_str() { + "start" => start_vote(s, p, CMD)?, + "cast" | "retract" => change_ballot(s, p, CMD, &who, action == "cast")?, + "end" => { + running(CMD, s.doc()?)?; + if let Some(v) = s.doc_mut()?.vote.as_mut() { + v.ended = true; + } + } + "clear" => { + let doc = s.doc()?; + if doc.vote.is_some() || !doc.votes.is_empty() { + let doc = s.doc_mut()?; + doc.vote = None; + doc.votes.clear(); + } + } + other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))), + } + Ok(vote_json(s.doc()?, &who)) +} + +/// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results. +fn start_vote(s: &mut Session, p: &Value, cmd: &str) -> Result<()> { + if s.doc()?.vote.as_ref().is_some_and(|v| !v.ended) { + return Err(EngineError::Disabled(cmd.into(), "a vote is already running: end it first".into())); + } + let per = match p.get("votes").filter(|v| !v.is_null()) { + Some(_) => num(p, "votes") + .filter(|n| n.fract() == 0.0 && (1.0..=f64::from(VOTES_MAX)).contains(n)) + .ok_or_else(|| bad(cmd, "`votes` must be a whole number from 1 to 99"))?, + None => f64::from(VOTES), + } as u32; + let scope = match string(p, "on").as_deref().unwrap_or("all") { + "all" => VoteScope::All, + "stickies" => VoteScope::Stickies, + "selection" => { + let doc = s.doc()?; + let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| VoteScope::All.takes(doc, *i)).collect(); + if ids.is_empty() { + return Err(bad(cmd, "pick the objects to vote on (not pages)")); + } + VoteScope::Only { ids } + } + other => return Err(bad(cmd, format!("unknown `on` `{other}` (all|stickies|selection)"))), + }; + let by = crate::board::author(s); + let doc = s.doc_mut()?; + doc.votes.clear(); + doc.vote = Some(Vote { per_person: per, scope, ended: false, by }); + Ok(()) +} + +/// `board.vote {action: cast|retract, id}`: one vote of `who`'s placed on (`cast`) or taken back +/// from an object, within the running vote's scope and votes per person. +fn change_ballot(s: &mut Session, p: &Value, cmd: &str, who: &str, cast: bool) -> Result<()> { + let target = id(p, "id").ok_or_else(|| bad(cmd, "`id` must be the object to vote on"))?; + let doc = s.doc()?; + let vote = running(cmd, doc)?; + let mut ballot: Vec = vote.counted(doc, doc.votes.get(who).map_or(&[], Vec::as_slice)).collect(); + if cast { + if !vote.scope.takes(doc, target) { + return Err(match doc.node(target) { + None => EngineError::NoNode(target), + Some(_) => EngineError::Disabled(cmd.into(), "this vote isn't on that object".into()), + }); + } + if ballot.len() >= vote.per_person as usize { + return Err(EngineError::Disabled(cmd.into(), "you have no votes left: remove one first".into())); + } + ballot.push(target); + } else { + let i = + ballot.iter().rposition(|i| *i == target).ok_or_else(|| EngineError::Disabled(cmd.into(), "you have no vote on that object".into()))?; + ballot.remove(i); + } + let votes = &mut s.doc_mut()?.votes; + if ballot.is_empty() { + votes.remove(who); + } else { + votes.insert(who.to_string(), ballot); + } + Ok(()) +} + +/// `who`'s votes per object, in the order first voted on. +pub fn mine(doc: &Document, who: &str) -> Vec<(NodeId, u32)> { + let (Some(vote), Some(ballot)) = (&doc.vote, doc.votes.get(who)) else { return Vec::new() }; + let mut out: Vec<(NodeId, u32)> = Vec::new(); + for id in vote.counted(doc, ballot) { + match out.iter_mut().find(|(i, _)| *i == id) { + Some((_, n)) => *n += 1, + None => out.push((id, 1)), + } + } + out +} + +/// What an object is called in the results: its text (a sticky's note), else its name. +pub fn object_name(doc: &Document, id: NodeId) -> String { + let text = text_of(doc, id).and_then(|t| doc.node(t)).and_then(|n| n.kind.text()).map(|t| t.characters.trim().to_string()); + let name = text.filter(|t| !t.is_empty()).or_else(|| doc.node(id).map(|n| n.name.clone())).unwrap_or_default(); + let line = name.lines().next().unwrap_or_default(); + let short: String = line.chars().take(60).collect(); + if short.len() < name.len() { format!("{short}…") } else { short } +} + +fn vote_json(doc: &Document, who: &str) -> Value { + let Some(vote) = &doc.vote else { return json!({"state": "off"}) }; + let on = match &vote.scope { + VoteScope::All => json!("all"), + VoteScope::Stickies => json!("stickies"), + VoteScope::Only { ids } => json!({"selection": ids}), + }; + let mine = mine(doc, who); + let used: u32 = mine.iter().map(|m| m.1).sum(); + let voters = doc.votes.values().filter(|b| vote.counted(doc, b).next().is_some()).count(); + let mut out = json!({ + "state": if vote.ended { "ended" } else { "running" }, + "votesPerPerson": vote.per_person, + "on": on, + "by": vote.by, + "left": vote.per_person.saturating_sub(used), + "mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::>(), + "voters": voters, + }); + if vote.ended { + let results: Vec = + ranked(doc, vote).into_iter().map(|(rank, id, n)| json!({"id": id, "name": object_name(doc, id), "votes": n, "rank": rank})).collect(); + out["results"] = json!(results); + } + out +} + +/// The objects with votes, most first, each with its rank (ties share one: "1, 2, 2, 4"). +pub fn ranked(doc: &Document, vote: &Vote) -> Vec<(usize, NodeId, u32)> { + let mut last = None; + let mut rank = 0; + vote.tally(doc, &doc.votes) + .into_iter() + .enumerate() + .map(|(i, (id, n))| { + if last != Some(n) { + rank = i + 1; + last = Some(n); + } + (rank, id, n) + }) + .collect() +} + +/// Where object `b`'s vote badge sits (its top-right corner) and its radius, in page units at +/// `zoom` screen pixels per unit. +pub fn badge(b: Rect, zoom: f64) -> (Point, f64) { + (Point::new(b.x1, b.y0), BADGE_PX / zoom.max(1e-6)) +} + +impl Session { + /// A press with the Move tool while a vote runs: on one of your vote badges, or with Alt, it + /// takes that vote back; on an object the vote is on, it puts a vote there. False when the + /// press is for the tool (no vote running, or nothing to vote on there). + pub(crate) fn vote_down(&mut self, ev: &PointerEvent) -> Result { + let doc = self.doc()?; + let Some(vote) = doc.vote.as_ref().filter(|v| !v.ended) else { return Ok(false) }; + let who = voter(self, &Value::Null); + let p = ev.p(); + let on_badge = mine(doc, &who).into_iter().map(|(id, _)| id).find(|id| { + doc.world_bounds(*id).is_some_and(|b| { + let (c, r) = badge(b, ev.zoom); + (p - c).hypot() <= r + }) + }); + let hit = || { + let leaf = self.hit(doc, self.page().ok()?, p, 2.0 / ev.zoom.max(1e-6))?; + vote.scope.target(doc, leaf, figcraft_doc::geometry::select_target(doc, leaf, &[], false)) + }; + let (action, target) = match on_badge { + Some(id) => ("retract", id), + None => match hit() { + Some(id) => (if ev.alt { "retract" } else { "cast" }, id), + None => return Ok(false), + }, + }; + self.execute("board.vote", &json!({"action": action, "id": target}))?; + Ok(true) + } +} + +/// Undo and redo bring back a document as it was, but the timer and the vote aren't undoable: +/// keep them as they are now (`from`). +pub(crate) fn keep(from: &Document, to: &mut Arc) { + if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes { + let d = Arc::make_mut(to); + d.timer = from.timer; + d.vote = from.vote.clone(); + d.votes = from.votes.clone(); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c7b2c5b3..20dd488c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -5,6 +5,7 @@ pub mod comment; pub mod component; pub mod create; pub mod edit; +pub mod facilitate; pub mod file; pub mod image; pub mod layout; @@ -113,6 +114,7 @@ fn registry() -> &'static Registry { list.extend(crate::datafill::specs()); list.extend(crate::textfit::specs()); list.extend(crate::board::specs()); + list.extend(facilitate::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/facilitate_tests.rs b/crates/engine/src/facilitate_tests.rs new file mode 100644 index 00000000..1db0b67a --- /dev/null +++ b/crates/engine/src/facilitate_tests.rs @@ -0,0 +1,324 @@ +//! A board's timer and votes: the commands, what undo leaves alone, saving, live sessions, clicks +//! that vote, and junk parameters. + +use figcraft_doc::{BoardTimer, NodeId, VoteScope}; +use serde_json::{Value, json}; + +use crate::board_tests::{board, id}; +use crate::cmd::facilitate::{clock, timer_after, timer_state}; +use crate::collab_live_tests::Room; +use crate::tools::{PointerEvent, PointerKind}; +use crate::{EngineError, Session}; + +const MIN: u64 = 60_000; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn sticky(s: &mut Session, x: f64, text: &str) -> NodeId { + id(&ex(s, "board.sticky", json!({"x": x, "y": 0, "text": text})), "id") +} + +#[test] +fn the_timer_counts_from_its_end_time() { + let now = 1_000_000; + let none = json!({}); + let t = timer_after("t", None, "start", &json!({"minutes": 2}), now).unwrap(); + assert_eq!(t, Some(BoardTimer::Running { ends: now + 2 * MIN })); + assert_eq!(timer_state(t, now + MIN), "running"); + assert_eq!(timer_state(t, now + 3 * MIN), "done", "run out: done until stopped"); + // Paused half way: the time left stays put however long the pause. + let p = timer_after("t", t, "pause", &none, now + MIN).unwrap(); + assert_eq!(p, Some(BoardTimer::Paused { left: MIN })); + assert_eq!(p.unwrap().left(now + 100 * MIN), MIN); + // A minute more while paused, then on again. + let p = timer_after("t", p, "add", &none, now + 5 * MIN).unwrap(); + assert_eq!(p, Some(BoardTimer::Paused { left: 2 * MIN })); + let r = timer_after("t", p, "resume", &none, now + 10 * MIN).unwrap(); + assert_eq!(r, Some(BoardTimer::Running { ends: now + 12 * MIN })); + // Adding to a timer that ran out starts from now. + let r = timer_after("t", r, "add", &json!({"minutes": 0.5}), now + 20 * MIN).unwrap(); + assert_eq!(r, Some(BoardTimer::Running { ends: now + 20 * MIN + MIN / 2 })); + assert_eq!(timer_after("t", r, "stop", &none, now).unwrap(), None); + // What can't be done says so. + assert!(timer_after("t", None, "pause", &none, now).is_err()); + assert!(timer_after("t", None, "add", &none, now).is_err()); + assert!(timer_after("t", r, "resume", &none, now).is_err()); + assert!(timer_after("t", Some(BoardTimer::Running { ends: now }), "pause", &none, now).is_err(), "nothing left to pause"); + for m in [json!(0), json!(-1), json!(1441), json!("NaN"), json!(1e308), json!("x"), json!([1])] { + assert!(timer_after("t", None, "start", &json!({"minutes": m}), now).is_err(), "{m}"); + } + // At most a day, also by adding. + let long = timer_after("t", None, "start", &json!({"minutes": 1440}), now).unwrap(); + assert_eq!(timer_after("t", long, "add", &none, now).unwrap().unwrap().left(now), 1440 * MIN); + assert!(timer_after("t", None, "start", &json!({"minutes": 1}), u64::MAX).is_ok(), "no overflow"); +} + +#[test] +fn clocks_read_minutes_and_seconds() { + assert_eq!(clock(0), "0:00"); + assert_eq!(clock(1), "0:01", "a part of a second left still shows"); + assert_eq!(clock(5 * MIN), "5:00"); + assert_eq!(clock(65_000), "1:05"); + assert_eq!(clock(3_723_000), "1:02:03"); +} + +#[test] +fn timer_commands_drive_the_boards_timer() { + let mut s = board(); + assert_eq!(ex(&mut s, "board.timerState", json!({}))["state"], "off"); + let r = ex(&mut s, "board.timer", json!({"action": "start", "minutes": 3})); + assert_eq!(r["state"], "running"); + let left = r["left"].as_u64().unwrap(); + assert!(left > 3 * MIN - 5000 && left <= 3 * MIN, "{r}"); + assert!(r["ends"].as_u64().is_some()); + assert_eq!(ex(&mut s, "board.timer", json!({"action": "pause"}))["state"], "paused"); + let r = ex(&mut s, "board.timer", json!({"action": "add"})); + assert!(r["left"].as_u64().unwrap() > 4 * MIN - 5000, "{r}"); + assert_eq!(ex(&mut s, "board.timer", json!({"action": "resume"}))["state"], "running"); + assert_eq!(ex(&mut s, "board.timer", json!({"action": "stop"}))["state"], "off"); + assert!(s.doc().unwrap().timer.is_none()); + // Not an undo step. + assert_eq!(s.undo_depth().0, 0); + assert!(matches!(s.execute("board.timer", &json!({"action": "pause"})), Err(EngineError::Disabled(..)))); + assert!(s.execute("board.timer", &json!({"action": "jump"})).is_err()); + // Boards only. + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + for (cmd, p) in [ + ("board.timer", json!({"action": "start"})), + ("board.timerState", json!({})), + ("board.vote", json!({"action": "start"})), + ("board.voteResults", json!({})), + ] { + assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd}"); + } +} + +#[test] +fn votes_are_capped_hidden_until_the_end_and_ranked() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "Ship it"); + let b = sticky(&mut s, 300.0, "Wait"); + let c = sticky(&mut s, 600.0, "Rethink"); + let shape = id(&ex(&mut s, "board.shape", json!({"x": 0, "y": 400})), "id"); + let r = ex(&mut s, "board.vote", json!({"action": "start", "votes": 2, "on": "stickies"})); + assert_eq!((r["state"].as_str(), r["left"].as_u64(), r["on"].as_str()), (Some("running"), Some(2), Some("stickies"))); + assert!(matches!(s.execute("board.vote", &json!({"action": "start"})), Err(EngineError::Disabled(..))), "one vote at a time"); + let cast = |s: &mut Session, who: &str, on: NodeId| s.execute("board.vote", &json!({"action": "cast", "id": on, "voter": who})); + // Ada puts both her votes on one sticky; a third is refused, and so is a shape. + cast(&mut s, "Ada", a).unwrap(); + let r = cast(&mut s, "Ada", a).unwrap(); + assert_eq!((r["left"].as_u64(), r["mine"].clone()), (Some(0), json!([{"id": a.0, "votes": 2}]))); + assert!(matches!(cast(&mut s, "Ada", b), Err(EngineError::Disabled(..))), "no votes left"); + assert!(matches!(cast(&mut s, "Bo", shape), Err(EngineError::Disabled(..))), "stickies only"); + assert!(matches!(cast(&mut s, "Bo", NodeId(99_999)), Err(EngineError::NoNode(_)))); + cast(&mut s, "Bo", b).unwrap(); + cast(&mut s, "Bo", c).unwrap(); + cast(&mut s, "Cy", b).unwrap(); + cast(&mut s, "Cy", c).unwrap(); + // Cy takes one back and puts it elsewhere. + s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).unwrap(); + assert!(s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).is_err(), "no vote there any more"); + cast(&mut s, "Cy", a).unwrap(); + // While it runs, you see your own votes only. + let r = ex(&mut s, "board.voteResults", json!({"voter": "Bo"})); + assert!(r.get("results").is_none(), "{r}"); + assert_eq!(r["mine"], json!([{"id": b.0, "votes": 1}, {"id": c.0, "votes": 1}])); + assert_eq!(r["voters"], 3); + assert_eq!(s.undo_depth().0, 4, "votes are no undo steps (only the four objects)"); + ex(&mut s, "board.vote", json!({"action": "end"})); + assert!(s.execute("board.vote", &json!({"action": "cast", "id": b, "voter": "Dee"})).is_err(), "ended"); + let r = ex(&mut s, "board.voteResults", json!({})); + assert_eq!(r["state"], "ended"); + assert_eq!( + r["results"], + json!([ + {"id": a.0, "name": "Ship it", "votes": 3, "rank": 1}, + {"id": b.0, "name": "Wait", "votes": 2, "rank": 2}, + {"id": c.0, "name": "Rethink", "votes": 1, "rank": 3}, + ]) + ); + // A deleted object's votes no longer count. + ex(&mut s, "edit.delete", json!({"ids": [a]})); + assert_eq!(ex(&mut s, "board.voteResults", json!({}))["results"][0]["id"], b.0); + ex(&mut s, "board.vote", json!({"action": "clear"})); + assert_eq!(ex(&mut s, "board.voteResults", json!({}))["state"], "off"); + assert!(s.doc().unwrap().votes.is_empty()); +} + +#[test] +fn a_vote_on_the_selection_takes_only_those() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + let b = sticky(&mut s, 300.0, "B"); + ex(&mut s, "select.set", json!({"ids": [a]})); + let r = ex(&mut s, "board.vote", json!({"action": "start", "on": "selection"})); + assert_eq!(r["on"], json!({"selection": [a.0]})); + assert_eq!(r["votesPerPerson"], 3); + assert!(s.execute("board.vote", &json!({"action": "cast", "id": b})).is_err()); + ex(&mut s, "board.vote", json!({"action": "cast", "id": a})); + // A finished vote may be replaced by a new one, which starts with no votes. + ex(&mut s, "board.vote", json!({"action": "end"})); + ex(&mut s, "board.vote", json!({"action": "start", "on": "all", "votes": 1})); + assert!(s.doc().unwrap().votes.is_empty()); + assert_eq!(s.doc().unwrap().vote.as_ref().unwrap().scope, VoteScope::All); + for bad in [json!({"votes": 0}), json!({"votes": 100}), json!({"votes": 1.5}), json!({"on": "pages"}), json!({"on": "selection", "ids": []})] { + let mut p = bad.clone(); + p["action"] = json!("start"); + ex(&mut s, "board.vote", json!({"action": "clear"})); + assert!(s.execute("board.vote", &p).is_err(), "{p}"); + } +} + +#[test] +fn undo_leaves_the_timer_and_the_vote_alone() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + ex(&mut s, "board.timer", json!({"action": "start"})); + ex(&mut s, "board.vote", json!({"action": "start"})); + ex(&mut s, "board.vote", json!({"action": "cast", "id": a})); + let b = sticky(&mut s, 300.0, "B"); + s.undo().unwrap(); + s.undo().unwrap(); + let doc = s.doc().unwrap(); + assert!(doc.node(a).is_none() && doc.node(b).is_none(), "the stickies are undone"); + assert!(doc.timer.is_some() && doc.vote.is_some() && doc.votes.len() == 1, "the timer and the vote stay"); + ex(&mut s, "board.timer", json!({"action": "stop"})); + s.redo().unwrap(); + assert!(s.doc().unwrap().timer.is_none(), "redo doesn't bring a stopped timer back"); + assert!(s.doc().unwrap().node(a).is_some()); +} + +#[test] +fn the_timer_and_votes_save_and_old_files_open() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + ex(&mut s, "board.timer", json!({"action": "start"})); + ex(&mut s, "board.timer", json!({"action": "pause"})); + ex(&mut s, "board.vote", json!({"action": "start", "on": "stickies"})); + ex(&mut s, "board.vote", json!({"action": "cast", "id": a})); + let doc = s.doc().unwrap().clone(); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + let (back, _) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!((back.timer, &back.vote, &back.votes), (doc.timer, &doc.vote, &doc.votes)); + // A file from before: none of the fields. + let mut v = figcraft_format::to_json(&doc).unwrap(); + for k in ["timer", "vote", "votes"] { + assert!(v.as_object_mut().unwrap().remove(k).is_some(), "{k} is saved"); + } + let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap(); + assert!(old.timer.is_none() && old.vote.is_none() && old.votes.is_empty()); + // Design files don't write them. + let plain = figcraft_format::to_json(&figcraft_doc::Document::new("D")).unwrap(); + assert!(plain.get("timer").is_none() && plain.get("vote").is_none() && plain.get("votes").is_none()); +} + +#[test] +fn everyone_in_a_live_session_shares_the_timer_and_the_vote() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + let b = sticky(&mut s, 300.0, "B"); + ex(&mut s, "board.timer", json!({"action": "start", "minutes": 10})); + let mut room = Room::new(s.doc().unwrap().clone(), 2, 11); + assert!(room.s(1).doc().unwrap().timer.is_some(), "joiners get the running timer"); + let t0 = room.s(0).doc().unwrap().timer; + assert_eq!(room.s(1).doc().unwrap().timer, t0); + ex(room.s(1), "board.timer", json!({"action": "pause"})); + ex(room.s(0), "board.vote", json!({"action": "start", "votes": 2})); + room.settle(); + room.check(); + assert!(matches!(room.s(0).doc().unwrap().timer, Some(BoardTimer::Paused { .. })), "a pause reaches everyone"); + // Both vote at once: each ballot is its own entry, so neither is lost. + ex(room.s(0), "board.vote", json!({"action": "cast", "id": a})); + ex(room.s(1), "board.vote", json!({"action": "cast", "id": a})); + ex(room.s(1), "board.vote", json!({"action": "cast", "id": b})); + room.settle(); + room.check(); + assert_eq!(room.server.document().votes.len(), 2, "two voters, apart though both are called the same"); + assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["mine"], json!([{"id": a.0, "votes": 1}])); + ex(room.s(1), "board.vote", json!({"action": "end"})); + room.settle(); + let r = ex(room.s(0), "board.voteResults", json!({})); + assert_eq!(r["results"], json!([{"id": a.0, "name": "A", "votes": 2, "rank": 1}, {"id": b.0, "name": "B", "votes": 1, "rank": 2}])); + // Undo in a session takes back your own edits only, not the vote. + room.s(0).undo().unwrap(); + room.settle(); + room.check(); + assert!(room.server.document().vote.is_some()); +} + +#[test] +fn clicks_vote_while_a_vote_runs() { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + ex(&mut s, "select.none", json!({})); + let click = |s: &mut Session, x: f64, y: f64, alt: bool| { + let mut down = PointerEvent::at(PointerKind::Down, x, y, 1.0); + down.alt = alt; + s.pointer(&down).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); + }; + // No vote: a click selects. + click(&mut s, 100.0, 100.0, false); + assert_eq!(s.selection(), vec![a]); + ex(&mut s, "select.none", json!({})); + ex(&mut s, "board.vote", json!({"action": "start"})); + click(&mut s, 100.0, 100.0, false); + click(&mut s, 50.0, 150.0, false); + assert!(s.selection().is_empty(), "votes, not selection"); + assert_eq!(crate::cmd::facilitate::mine(s.doc().unwrap(), "You"), vec![(a, 2)]); + // Alt takes one back; so does a click on your badge (the sticky's top-right corner). + click(&mut s, 100.0, 100.0, true); + click(&mut s, 199.0, 1.0, false); + assert!(s.doc().unwrap().votes.is_empty()); + // Empty canvas is the tool's as ever. + click(&mut s, 900.0, 900.0, false); + assert!(s.doc().unwrap().votes.is_empty()); +} + +#[test] +fn facilitation_commands_survive_junk() { + let junk = [ + json!({}), + json!(null), + json!([1]), + json!({"action": 5, "minutes": "NaN", "votes": -1, "on": [1], "id": "x", "voter": {"a": 1}}), + json!({"action": "start", "minutes": 1e308, "votes": 1e308, "on": "selection", "ids": ["x", -1]}), + json!({"action": "add", "minutes": -1e308}), + json!({"action": "cast", "id": 99_999, "voter": "\u{0}\u{7}"}), + json!({"action": "retract", "id": -1, "voter": "x".repeat(10_000)}), + json!({"action": "end"}), + json!({"action": "clear", "ids": 7}), + json!({"action": "resume", "minutes": [1]}), + ]; + for cmd in ["board.timer", "board.timerState", "board.vote", "board.voteResults"] { + for j in &junk { + let mut s = board(); + let a = sticky(&mut s, 0.0, "A"); + ex(&mut s, "board.timer", json!({"action": "start"})); + ex(&mut s, "board.vote", json!({"action": "start"})); + ex(&mut s, "board.vote", json!({"action": "cast", "id": a})); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + // A voter name is kept short. + assert!(s.doc().unwrap().votes.keys().all(|k| k.chars().count() <= 64)); + } + } +} + +#[test] +fn ties_share_a_rank() { + let mut s = board(); + let ids: Vec = (0..4).map(|i| sticky(&mut s, f64::from(i) * 300.0, "x")).collect(); + ex(&mut s, "board.vote", json!({"action": "start", "votes": 2})); + for (who, on) in [("a", 0), ("a", 1), ("b", 1), ("b", 0), ("c", 2), ("c", 3), ("d", 2)] { + ex(&mut s, "board.vote", json!({"action": "cast", "id": ids[on], "voter": who})); + } + ex(&mut s, "board.vote", json!({"action": "end"})); + let r = ex(&mut s, "board.voteResults", json!({})); + let ranks: Vec = r["results"].as_array().unwrap().iter().map(|x| x["rank"].as_u64().unwrap()).collect(); + assert_eq!(ranks, vec![1, 1, 1, 4], "{r}"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79856c65..5d2ee69d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1213,8 +1213,9 @@ impl Session { } let Some(e) = ds.history.undo.pop() else { return Ok(false) }; let redo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page }; - ds.history.redo.push(redo); ds.doc = e.doc; + cmd::facilitate::keep(&redo.doc, &mut ds.doc); + ds.history.redo.push(redo); ds.selection = e.selection; if ds.doc.pages.contains(&e.page) { ds.page = e.page; @@ -1249,8 +1250,9 @@ impl Session { } let Some(e) = ds.history.redo.pop() else { return Ok(false) }; let undo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page }; - ds.history.undo.push(undo); ds.doc = e.doc; + cmd::facilitate::keep(&undo.doc, &mut ds.doc); + ds.history.undo.push(undo); ds.selection = e.selection; if ds.doc.pages.contains(&e.page) { ds.page = e.page; @@ -1712,6 +1714,8 @@ mod export_depth_tests; #[cfg(test)] mod extended_chain_tests; #[cfg(test)] +mod facilitate_tests; +#[cfg(test)] mod fig_export_assets_tests; #[cfg(test)] mod fig_export_tests; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index e63acbb6..3e2dcd79 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1110,6 +1110,10 @@ impl Session { PointerKind::Hover => self.hover(ev), PointerKind::Down => match tool { ToolId::Move | ToolId::Scale => { + // While a board's vote runs, a click on an object votes. + if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? { + return Ok(()); + } // A click on the canvas deselects a ruler guide (the UI selects guides itself). self.ds_mut()?.guide = None; self.move_down(ev) diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 2ee9d325..03cecc4c 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -37,6 +37,7 @@ status = "draft — needs native review" "Add a description" = "একটি বিবরণ যোগ করুন" "Add a frame (F) or a shape (R) to get started" = "শুরু করতে একটি ফ্রেম (F) বা শেপ (R) যোগ করুন" "Add a layer or component to tile first" = "প্রথমে টাইল করার জন্য একটি লেয়ার বা কম্পোনেন্ট যোগ করুন" +"Add a minute" = "এক মিনিট যোগ করুন" "Add a note for developers" = "ডেভেলপারদের জন্য একটি নোট যোগ করুন" "Add action" = "অ্যাকশন যোগ করুন" "Add actions for when no condition holds" = "কোনো শর্ত পূর্ণ না হলে তার জন্য অ্যাকশন যোগ করুন" @@ -92,6 +93,7 @@ status = "draft — needs native review" "All comments" = "সব মন্তব্য" "All components" = "সব কম্পোনেন্ট" "All fonts" = "সব ফন্ট" +"All objects" = "সব অবজেক্ট" "All supported properties" = "সমর্থিত সব প্রপার্টি" "All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে" "All variables" = "সব ভেরিয়েবল" @@ -211,6 +213,8 @@ status = "draft — needs native review" "Checking the server…" = "সার্ভার চেক করা হচ্ছে..." "Chevron" = "শেভরন" "Clear from this list" = "এই তালিকা থেকে মুছে ফেলুন" +"Clear results" = "ফলাফল মুছুন" +"Click objects to vote; click your badge to take a vote back" = "ভোট দিতে অবজেক্টে ক্লিক করুন; ভোট ফেরাতে আপনার ব্যাজে ক্লিক করুন" "Code or invite link" = "কোড বা আমন্ত্রণ লিংক" "Color profile: {name}" = "কালার প্রোফাইল: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "রঙ ও টেক্সট স্টাইল, লাইট এবং ডার্ক ভেরিয়েবল, বোতাম, ইনপুট এবং কার্ড" @@ -461,6 +465,7 @@ status = "draft — needs native review" "Empty" = "খালি" "End" = "শেষ" "End point" = "শেষ বিন্দু" +"End vote" = "ভোট শেষ করুন" "Ends the session; everyone keeps their copy" = "সেশন শেষ করে; সবাই তাদের কপি নিজের কাছে রাখে" "Enter" = "এন্টার" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "একটি figcraft-সার্ভারের ঠিকানা দিন এবং এটি যে প্লাগইনগুলো অফার করে তা দেখতে ব্রাউজ টিপুন।" @@ -790,6 +795,7 @@ status = "draft — needs native review" "Min H" = "সর্বনিম্ন উচ্চতা" "Min W" = "সর্বনিম্ন প্রস্থ" "Minimize" = "ছোট করুন" +"Minutes" = "মিনিট" "Mirror angle" = "মিরর কোণ" "Mirror angle and length" = "মিরর কোণ ও দৈর্ঘ্য" "Mirroring" = "মিররিং" @@ -887,6 +893,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "এখনো কোনো টেক্সট স্টাইল নেই। নির্বাচিত টেক্সট থেকে একটি তৈরি করতে + এ ক্লিক করুন।" "No variables fit here." = "এখানে কোনো ভেরিয়েবল মানানসই নয়।" "No variables yet. Create them in Local variables." = "এখনো কোনো ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No votes yet" = "এখনও কোনো ভোট নেই" "Nobody else is here yet" = "এখানে আর কেউ নেই" "Noise" = "নয়েজ" "None" = "কিছুই না" @@ -973,6 +980,7 @@ status = "draft — needs native review" "Page" = "পেজ" "Pages" = "পেজসমূহ" "Pages added to the document" = "ডকুমেন্টে পেজ যোগ করা হয়েছে" +"Pause" = "বিরতি" "Pink" = "গোলাপি" "Placed on the current page" = "বর্তমান পেজে রাখা হয়েছে" "Pages: {pages}" = "পেজসমূহ: {pages}" @@ -1154,6 +1162,8 @@ status = "draft — needs native review" "Restore main component" = "প্রধান কম্পোনেন্ট পুনরুদ্ধার করুন" "Restore this version" = "এই সংস্করণ পুনরুদ্ধার করুন" "Restored “{name}”" = "“{name}” পুনরুদ্ধার করা হয়েছে" +"Results" = "ফলাফল" +"Resume" = "আবার চালু করুন" "Reverse the order" = "ক্রম উল্টে দিন" "Reversed triangle" = "উল্টানো ত্রিভুজ" "Review and merge" = "পর্যালোচনা ও মার্জ করুন" @@ -1332,17 +1342,20 @@ status = "draft — needs native review" "Start point" = "শুরুর বিন্দু" "Start sharing" = "শেয়ার করা শুরু করুন" "Start sharing with the Share button, then pass on the code it gives" = "শেয়ার বোতাম দিয়ে শেয়ার করা শুরু করুন, তারপর এটি যে কোড দেয় তা দিন" +"Start vote" = "ভোট শুরু করুন" "Starting to share…" = "শেয়ার করা শুরু হচ্ছে..." "Status" = "স্ট্যাটাস" "Sticky" = "স্টিকি" "Sticky note" = "স্টিকি নোট" "Sticky note colors" = "স্টিকি নোটের রং" +"Sticky notes" = "স্টিকি নোট" "Sticky notes, shapes and connectors" = "স্টিকি নোট, আকৃতি ও সংযোগকারী" "Stop" = "থামুন" "Stop following {name}" = "{name}-কে অনুসরণ করা বন্ধ করুন" "Stop session" = "সেশন বন্ধ করুন" "Stop sharing" = "শেয়ার করা বন্ধ করুন" "Stop spotlighting" = "স্পটলাইটিং বন্ধ করুন" +"Stop timer" = "টাইমার থামান" "Stop {n} {hex}" = "স্টপ {n} {hex}" "Straight" = "সোজা" "Stretch" = "টেনে বড় করা (Stretch)" @@ -1428,6 +1441,7 @@ status = "draft — needs native review" "Tidy up" = "পরিপাটি করুন" "Tile" = "টাইল" "Tile type" = "টাইল টাইপ" +"Timer" = "টাইমার" "Tint" = "টিন্ট" "Title" = "শিরোনাম" "Title case" = "টাইটেল কেস" @@ -1523,6 +1537,8 @@ status = "draft — needs native review" "Visibility" = "দৃশ্যমানতা" "Visible" = "দৃশ্যমান" "Vivid" = "ভিভিড" +"Votes per person" = "জনপ্রতি ভোট" +"Voting session" = "ভোটিং সেশন" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND সেট করা আছে এবং ব্যবহারের জন্য গ্রাফিক্স API-এর নাম দেয়; এটি আনসেট করলে FigCraft কে বেছে নিতে দেয়।" "Warm" = "উষ্ণ" "Watch" = "ওয়াচ" @@ -1741,6 +1757,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n}টি লেয়ার নির্বাচিত", other = "{n}টি লেয়ার নির্বাচিত" } "{n} layers · {parts}" = { one = "{n}টি লেয়ার · {parts}", other = "{n}টি লেয়ার · {parts}" } "{n} matches" = { one = "{n}টি মিল", other = "{n}টি মিল" } +"{n} min" = "{n} মিনিট" "{n} minutes ago" = { one = "{n} মিনিট আগে", other = "{n} মিনিট আগে" } "{n} months ago" = { one = "{n} মাস আগে", other = "{n} মাস আগে" } "{n} more notes omitted from this report" = { one = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে", other = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে" } @@ -1754,6 +1771,8 @@ status = "draft — needs native review" "{n} styles" = { one = "{n}টি স্টাইল", other = "{n}টি স্টাইল" } "{n} text layers" = { one = "{n}টি টেক্সট লেয়ার", other = "{n}টি টেক্সট লেয়ার" } "{n} variables" = { one = "{n}টি ভেরিয়েবল", other = "{n}টি ভেরিয়েবল" } +"{n} votes" = { one = "{n}টি ভোট", other = "{n}টি ভোট" } +"{n} votes left" = { one = "{n}টি ভোট বাকি", other = "{n}টি ভোট বাকি" } "{n} weeks ago" = { one = "{n} সপ্তাহ আগে", other = "{n} সপ্তাহ আগে" } "{n} years ago" = { one = "{n} বছর আগে", other = "{n} বছর আগে" } "{n}d" = "{n}d" @@ -1792,6 +1811,10 @@ status = "draft — needs native review" "board.shape" = "লেখাসহ আকৃতি" "board.stamp" = "স্ট্যাম্প" "board.sticky" = "স্টিকি নোট" +"board.timer" = "টাইমার" +"board.timerState" = "টাইমারের অবস্থা" +"board.vote" = "ভোটিং সেশন" +"board.voteResults" = "ভোটের ফলাফল" "devMode.addMeasurement" = "মাপ যোগ করুন" "devMode.measurements" = "মাপের তালিকা" "devMode.removeMeasurement" = "মাপ সরান" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 0a48d105..4d71c66c 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -33,6 +33,7 @@ status = "draft — needs native review" "Add a description" = "Přidat popis" "Add a frame (F) or a shape (R) to get started" = "Začněte přidáním rámce (F) nebo tvaru (R)" "Add a layer or component to tile first" = "Nejprve přidejte vrstvu nebo komponentu k opakování" +"Add a minute" = "Přidat minutu" "Add a note for developers" = "Přidat poznámku pro vývojáře" "Add action" = "Přidat akci" "Add actions for when no condition holds" = "Přidat akce pro případ, kdy neplatí žádná podmínka" @@ -87,6 +88,7 @@ status = "draft — needs native review" "All comments" = "Všechny komentáře" "All components" = "Všechny komponenty" "All fonts" = "Všechna písma" +"All objects" = "Všechny objekty" "All supported properties" = "Všechny podporované vlastnosti" "All variables" = "Všechny proměnné" "Along the bottom" = "Podél dolní hrany" @@ -204,6 +206,8 @@ status = "draft — needs native review" "Checking the server…" = "Kontrola serveru…" "Chevron" = "Lomená šipka" "Clear from this list" = "Vymazat z tohoto seznamu" +"Clear results" = "Vymazat výsledky" +"Click objects to vote; click your badge to take a vote back" = "Kliknutím na objekty hlasujete; kliknutím na svůj odznak hlas vrátíte" "Code or invite link" = "Kód nebo odkaz s pozvánkou" "Color profile: {name}" = "Barevný profil: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Barevné a textové styly, proměnné pro světlý a tmavý režim, tlačítka, vstupní pole a karty" @@ -448,6 +452,7 @@ status = "draft — needs native review" "Empty" = "Prázdné" "End" = "Konec" "End point" = "Koncový bod" +"End vote" = "Ukončit hlasování" "Ends the session; everyone keeps their copy" = "Ukončí relaci; každý si ponechá svou kopii" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Zadejte adresu serveru figcraft-server a stisknutím Procházet zobrazte nabízené pluginy." @@ -775,6 +780,7 @@ status = "draft — needs native review" "Min H" = "Min. V" "Min W" = "Min. Š" "Minimize" = "Minimalizovat" +"Minutes" = "Minuty" "Mirror angle" = "Zrcadlit úhel" "Mirror angle and length" = "Zrcadlit úhel a délku" "Mirroring" = "Zrcadlení" @@ -869,6 +875,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Zatím žádné textové styly. Kliknutím na + vytvoříte styl z vybraného textu." "No variables fit here." = "Žádné vhodné proměnné." "No variables yet. Create them in Local variables." = "Zatím žádné proměnné. Vytvořte je v Místních proměnných." +"No votes yet" = "Zatím žádné hlasy" "Nobody else is here yet" = "Zatím tu není nikdo další" "Noise" = "Šum" "None" = "Žádné" @@ -954,6 +961,7 @@ status = "draft — needs native review" "Page deleted" = "Stránka smazána" "Pages" = "Stránky" "Pages added to the document" = "Stránky přidány do dokumentu" +"Pause" = "Pozastavit" "Pink" = "Růžová" "Placed on the current page" = "Umístěno na aktuální stránku" "Pages: {pages}" = "Stránky: {pages}" @@ -1132,6 +1140,8 @@ status = "draft — needs native review" "Restore main component" = "Obnovit hlavní komponentu" "Restore this version" = "Obnovit tuto verzi" "Restored “{name}”" = "Obnoveno: „{name}“" +"Results" = "Výsledky" +"Resume" = "Pokračovat" "Reverse the order" = "Obrátit pořadí" "Reversed triangle" = "Obrácený trojúhelník" "Review and merge" = "Zkontrolovat a sloučit" @@ -1307,17 +1317,20 @@ status = "draft — needs native review" "Start point" = "Počáteční bod" "Start sharing" = "Zahájit sdílení" "Start sharing with the Share button, then pass on the code it gives" = "Zahajte sdílení tlačítkem Sdílet a předejte získaný kód" +"Start vote" = "Zahájit hlasování" "Starting to share…" = "Zahajování sdílení…" "Status" = "Stav" "Sticky" = "Přichycené" "Sticky note" = "Lepicí poznámka" "Sticky note colors" = "Barvy lepicích poznámek" +"Sticky notes" = "Lepicí poznámky" "Sticky notes, shapes and connectors" = "Lepicí poznámky, tvary a spojnice" "Stop" = "Zastavit" "Stop following {name}" = "Přestat sledovat: {name}" "Stop session" = "Ukončit relaci" "Stop sharing" = "Zastavit sdílení" "Stop spotlighting" = "Ukončit sdílení pohledu" +"Stop timer" = "Zastavit časovač" "Stop {n} {hex}" = "Zarážka {n} {hex}" "Straight" = "Rovná" "Stretch" = "Roztáhnout" @@ -1399,6 +1412,7 @@ status = "draft — needs native review" "Tidy up" = "Uspořádat" "Tile" = "Dlaždice" "Tile type" = "Typ dlaždic" +"Timer" = "Časovač" "Tint" = "Tónování" "Title" = "Název" "Title case" = "Velká počáteční písmena slov" @@ -1491,6 +1505,8 @@ status = "draft — needs native review" "Visibility" = "Viditelnost" "Visible" = "Viditelné" "Vivid" = "Živé" +"Votes per person" = "Hlasů na osobu" +"Voting session" = "Hlasování" "Warm" = "Teplé" "Watch" = "Hodinky" "Wavy" = "Vlnitý" @@ -1706,6 +1722,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "Vybrána {n} vrstva", few = "Vybrány {n} vrstvy", other = "Vybráno {n} vrstev" } "{n} layers · {parts}" = { one = "{n} vrstva · {parts}", few = "{n} vrstvy · {parts}", other = "{n} vrstev · {parts}" } "{n} matches" = { one = "{n} shoda", few = "{n} shody", other = "{n} shod" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "Před {n} minutou", few = "Před {n} minutami", other = "Před {n} minutami" } "{n} months ago" = { one = "Před {n} měsícem", few = "Před {n} měsíci", other = "Před {n} měsíci" } "{n} more notes omitted from this report" = { one = "{n} další poznámka v této zprávě vynechána", few = "{n} další poznámky v této zprávě vynechány", other = "{n} dalších poznámek v této zprávě vynecháno" } @@ -1718,6 +1735,8 @@ status = "draft — needs native review" "{n} rows" = { one = "{n} řádek", few = "{n} řádky", other = "{n} řádků" } "{n} styles" = { one = "{n} styl", few = "{n} styly", other = "{n} stylů" } "{n} variables" = { one = "{n} proměnná", few = "{n} proměnné", other = "{n} proměnných" } +"{n} votes" = { one = "{n} hlas", few = "{n} hlasy", other = "{n} hlasů" } +"{n} votes left" = { one = "Zbývá {n} hlas", few = "Zbývají {n} hlasy", other = "Zbývá {n} hlasů" } "{n} weeks ago" = { one = "Před {n} týdnem", few = "Před {n} týdny", other = "Před {n} týdny" } "{n} years ago" = { one = "Před {n} rokem", few = "Před {n} lety", other = "Před {n} lety" } "{n}d" = "{n} d" @@ -1780,6 +1799,10 @@ status = "draft — needs native review" "board.shape" = "Tvar s textem" "board.stamp" = "Razítko" "board.sticky" = "Lepicí poznámka" +"board.timer" = "Časovač" +"board.timerState" = "Stav časovače" +"board.vote" = "Hlasování" +"board.voteResults" = "Výsledky hlasování" "bool.exclude" = "Vyloučit výběr" "bool.flatten" = "Sloučit booleovskou skupinu do vektoru" "bool.intersect" = "Průnik výběru" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 0e560ad6..4a527081 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -34,6 +34,7 @@ status = "draft — needs native review" "Add a description" = "Beschreibung hinzufügen" "Add a frame (F) or a shape (R) to get started" = "Fügen Sie zum Start einen Rahmen (F) oder eine Form (R) hinzu" "Add a layer or component to tile first" = "Fügen Sie zuerst eine Ebene oder Komponente zum Kacheln hinzu" +"Add a minute" = "Eine Minute hinzufügen" "Add a note for developers" = "Notiz für Entwickler hinzufügen" "Add action" = "Aktion hinzufügen" "Add actions for when no condition holds" = "Aktionen für den Fall hinzufügen, dass keine Bedingung zutrifft" @@ -89,6 +90,7 @@ status = "draft — needs native review" "All comments" = "Alle Kommentare" "All components" = "Alle Komponenten" "All fonts" = "Alle Schriften" +"All objects" = "Alle Objekte" "All supported properties" = "Alle unterstützten Eigenschaften" "All variables" = "Alle Variablen" "Along the bottom" = "Entlang der Unterkante" @@ -200,6 +202,8 @@ status = "draft — needs native review" "Checking the server…" = "Server wird geprüft…" "Chevron" = "Chevron" "Clear from this list" = "Aus dieser Liste entfernen" +"Clear results" = "Ergebnisse löschen" +"Click objects to vote; click your badge to take a vote back" = "Klicken Sie auf Objekte, um abzustimmen; klicken Sie auf Ihr Abzeichen, um eine Stimme zurückzunehmen" "Code or invite link" = "Code oder Einladungslink" "Color profile: {name}" = "Farbprofil: {name}" "CSS custom properties…" = "Benutzerdefinierte CSS-Eigenschaften…" @@ -443,6 +447,7 @@ status = "draft — needs native review" "Empty" = "Leer" "End" = "Ende" "End point" = "Endpunkt" +"End vote" = "Abstimmung beenden" "Ends the session; everyone keeps their copy" = "Beendet die Sitzung; alle behalten ihre Kopie" "Enter" = "Eingabe" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Geben Sie die Adresse eines figcraft-server ein und drücken Sie auf Durchsuchen, um die angebotenen Plugins zu sehen." @@ -752,6 +757,7 @@ status = "draft — needs native review" "Min H" = "Min. H" "Min W" = "Min. B" "Minimize" = "Minimieren" +"Minutes" = "Minuten" "Mirror angle" = "Winkel spiegeln" "Mirror angle and length" = "Winkel und Länge spiegeln" "Mirroring" = "Spiegelung" @@ -843,6 +849,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Noch keine Textstile. Klicken Sie auf +, um einen aus dem ausgewählten Text zu erstellen." "No variables fit here." = "Hier passen keine Variablen." "No variables yet. Create them in Local variables." = "Noch keine Variablen. Erstellen Sie sie unter Lokale Variablen." +"No votes yet" = "Noch keine Stimmen" "Nobody else is here yet" = "Noch niemand sonst hier" "Noise" = "Rauschen" "None" = "Keine" @@ -926,6 +933,7 @@ status = "draft — needs native review" "Page deleted" = "Seite gelöscht" "Pages" = "Seiten" "Pages added to the document" = "Seiten zum Dokument hinzugefügt" +"Pause" = "Pausieren" "Pink" = "Rosa" "Placed on the current page" = "Auf der aktuellen Seite platziert" "Pages: {pages}" = "Seiten: {pages}" @@ -1102,6 +1110,8 @@ status = "draft — needs native review" "Restore main component" = "Hauptkomponente wiederherstellen" "Restore this version" = "Diese Version wiederherstellen" "Restored “{name}”" = "„{name}“ wiederhergestellt" +"Results" = "Ergebnisse" +"Resume" = "Fortsetzen" "Reverse the order" = "Reihenfolge umkehren" "Reversed triangle" = "Umgekehrtes Dreieck" "Review and merge" = "Prüfen und zusammenführen" @@ -1264,17 +1274,20 @@ status = "draft — needs native review" "Start on the Home screen" = "Mit Start beginnen" "Start point" = "Startpunkt" "Start sharing" = "Teilen starten" +"Start vote" = "Abstimmung starten" "Starting to share…" = "Teilen wird gestartet…" "Status" = "Status" "Sticky" = "Fixiert" "Sticky note" = "Haftnotiz" "Sticky note colors" = "Haftnotizfarben" +"Sticky notes" = "Haftnotizen" "Sticky notes, shapes and connectors" = "Haftnotizen, Formen und Verbinder" "Stop" = "Beenden" "Stop following {name}" = "{name} nicht mehr folgen" "Stop session" = "Sitzung beenden" "Stop sharing" = "Teilen beenden" "Stop spotlighting" = "Spotlight beenden" +"Stop timer" = "Timer stoppen" "Stop {n} {hex}" = "Farbunterbrechung {n} {hex}" "Straight" = "Gerade" "Stretch" = "Strecken" @@ -1360,6 +1373,7 @@ status = "draft — needs native review" "Tidy up" = "Aufräumen" "Tile" = "Kachel" "Tile type" = "Kacheltyp" +"Timer" = "Timer" "Tint" = "Tönung" "Title" = "Titel" "Title case" = "Großschreibung am Wortanfang" @@ -1451,6 +1465,8 @@ status = "draft — needs native review" "Visibility" = "Sichtbarkeit" "Visible" = "Sichtbar" "Vivid" = "Lebhaft" +"Votes per person" = "Stimmen pro Person" +"Voting session" = "Abstimmung" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND ist gesetzt und legt die Grafik-API fest; ohne diese Variable wählt FigCraft selbst." "Warm" = "Warm" "Watch" = "Smartwatch" @@ -1662,6 +1678,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} Ebene ausgewählt", other = "{n} Ebenen ausgewählt" } "{n} layers · {parts}" = { one = "{n} Ebene · {parts}", other = "{n} Ebenen · {parts}" } "{n} matches" = { one = "{n} Treffer", other = "{n} Treffer" } +"{n} min" = "{n} Min." "{n} minutes ago" = { one = "vor {n} Minute", other = "vor {n} Minuten" } "{n} months ago" = { one = "vor {n} Monat", other = "vor {n} Monaten" } "{n} more notes omitted from this report" = { one = "{n} weitere Notiz in diesem Bericht nicht aufgeführt", other = "{n} weitere Notizen in diesem Bericht nicht aufgeführt" } @@ -1674,6 +1691,8 @@ status = "draft — needs native review" "{n} rows" = { one = "{n} Zeile", other = "{n} Zeilen" } "{n} styles" = { one = "{n} Stil", other = "{n} Stile" } "{n} variables" = { one = "{n} Variable", other = "{n} Variablen" } +"{n} votes" = { one = "{n} Stimme", other = "{n} Stimmen" } +"{n} votes left" = { one = "Noch {n} Stimme", other = "Noch {n} Stimmen" } "{n} weeks ago" = { one = "vor {n} Woche", other = "vor {n} Wochen" } "{n} years ago" = { one = "vor {n} Jahr", other = "vor {n} Jahren" } "{n}d" = "{n} T." @@ -1736,6 +1755,10 @@ status = "draft — needs native review" "board.shape" = "Form mit Text" "board.stamp" = "Stempel" "board.sticky" = "Haftnotiz" +"board.timer" = "Timer" +"board.timerState" = "Timer-Status" +"board.vote" = "Abstimmung" +"board.voteResults" = "Abstimmungsergebnisse" "bool.exclude" = "Auswahl ausschließen" "bool.flatten" = "Boolesche Gruppe zusammenfügen" "bool.intersect" = "Schnittmenge der Auswahl" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 532a3cfa..588a4599 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -36,6 +36,7 @@ name = "English" "Add a description" = "Add a description" "Add a frame (F) or a shape (R) to get started" = "Add a frame (F) or a shape (R) to get started" "Add a layer or component to tile first" = "Add a layer or component to tile first" +"Add a minute" = "Add a minute" "Add a note for developers" = "Add a note for developers" "Add action" = "Add action" "Add actions for when no condition holds" = "Add actions for when no condition holds" @@ -91,6 +92,7 @@ name = "English" "All comments" = "All comments" "All components" = "All components" "All fonts" = "All fonts" +"All objects" = "All objects" "All supported properties" = "All supported properties" "All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" @@ -214,6 +216,8 @@ name = "English" "Checking the server…" = "Checking the server…" "Chevron" = "Chevron" "Clear from this list" = "Clear from this list" +"Clear results" = "Clear results" +"Click objects to vote; click your badge to take a vote back" = "Click objects to vote; click your badge to take a vote back" "Code or invite link" = "Code or invite link" "Color profile: {name}" = "Color profile: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards" @@ -473,6 +477,7 @@ name = "English" "Empty" = "Empty" "End" = "End" "End point" = "End point" +"End vote" = "End vote" "Ends the session; everyone keeps their copy" = "Ends the session; everyone keeps their copy" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Enter the address of a figcraft-server and press Browse to see the plugins it offers." @@ -808,6 +813,7 @@ name = "English" "Min H" = "Min H" "Min W" = "Min W" "Minimize" = "Minimize" +"Minutes" = "Minutes" "Mirror angle" = "Mirror angle" "Mirror angle and length" = "Mirror angle and length" "Mirroring" = "Mirroring" @@ -905,6 +911,7 @@ name = "English" "No text styles yet. Click + to create one from the selected text." = "No text styles yet. Click + to create one from the selected text." "No variables fit here." = "No variables fit here." "No variables yet. Create them in Local variables." = "No variables yet. Create them in Local variables." +"No votes yet" = "No votes yet" "Nobody else is here yet" = "Nobody else is here yet" "Noise" = "Noise" "None" = "None" @@ -994,6 +1001,7 @@ name = "English" "Page deleted" = "Page deleted" "Pages" = "Pages" "Pages added to the document" = "Pages added to the document" +"Pause" = "Pause" "Pink" = "Pink" "Placed on the current page" = "Placed on the current page" "Pages: {pages}" = "Pages: {pages}" @@ -1181,6 +1189,8 @@ name = "English" "Restore main component" = "Restore main component" "Restore this version" = "Restore this version" "Restored “{name}”" = "Restored “{name}”" +"Results" = "Results" +"Resume" = "Resume" "Reverse the order" = "Reverse the order" "Reversed triangle" = "Reversed triangle" "Review and merge" = "Review and merge" @@ -1364,17 +1374,20 @@ name = "English" "Start point" = "Start point" "Start sharing" = "Start sharing" "Start sharing with the Share button, then pass on the code it gives" = "Start sharing with the Share button, then pass on the code it gives" +"Start vote" = "Start vote" "Starting to share…" = "Starting to share…" "Status" = "Status" "Sticky" = "Sticky" "Sticky note" = "Sticky note" "Sticky note colors" = "Sticky note colors" +"Sticky notes" = "Sticky notes" "Sticky notes, shapes and connectors" = "Sticky notes, shapes and connectors" "Stop" = "Stop" "Stop following {name}" = "Stop following {name}" "Stop session" = "Stop session" "Stop sharing" = "Stop sharing" "Stop spotlighting" = "Stop spotlighting" +"Stop timer" = "Stop timer" "Stop {n} {hex}" = "Stop {n} {hex}" "Straight" = "Straight" "Stretch" = "Stretch" @@ -1466,6 +1479,7 @@ name = "English" "Tidy up" = "Tidy up" "Tile" = "Tile" "Tile type" = "Tile type" +"Timer" = "Timer" "Tint" = "Tint" "Title" = "Title" "Title case" = "Title case" @@ -1564,6 +1578,8 @@ name = "English" "Visibility" = "Visibility" "Visible" = "Visible" "Vivid" = "Vivid" +"Votes per person" = "Votes per person" +"Voting session" = "Voting session" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." "Warm" = "Warm" "Watch" = "Watch" @@ -1787,6 +1803,7 @@ name = "English" "{n} layers selected" = { one = "{n} layer selected", other = "{n} layers selected" } "{n} layers · {parts}" = { one = "{n} layer · {parts}", other = "{n} layers · {parts}" } "{n} matches" = { one = "{n} match", other = "{n} matches" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "{n} minute ago", other = "{n} minutes ago" } "{n} months ago" = { one = "{n} month ago", other = "{n} months ago" } "{n} more notes omitted from this report" = { one = "{n} more note omitted from this report", other = "{n} more notes omitted from this report" } @@ -1800,6 +1817,8 @@ name = "English" "{n} styles" = { one = "{n} style", other = "{n} styles" } "{n} text layers" = { one = "{n} text layer", other = "{n} text layers" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } +"{n} votes" = { one = "{n} vote", other = "{n} votes" } +"{n} votes left" = { one = "{n} vote left", other = "{n} votes left" } "{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" } "{n} years ago" = { one = "{n} year ago", other = "{n} years ago" } "{n}d" = "{n}d" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 6ef81633..7683e25e 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -17,6 +17,7 @@ status = "draft — needs native review" "Add a description" = "Agregar una descripción" "Add a frame (F) or a shape (R) to get started" = "Agrega un marco (F) o una forma (R) para empezar" "Add a layer or component to tile first" = "Agrega antes una capa o un componente para el mosaico" +"Add a minute" = "Agregar un minuto" "Add a note for developers" = "Agrega una nota para desarrollo" "Add action" = "Agregar acción" "Add actions for when no condition holds" = "Agregar acciones para cuando no se cumpla ninguna condición" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index e994258b..efdc05f2 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,7 +8,9 @@ name = "Español" status = "draft — needs native review" [ui] +"Add a minute" = "Añadir un minuto" "Add measurement" = "Añadir medición" +"All objects" = "Todos los objetos" "Along the bottom" = "A lo largo del borde inferior" "Along the left" = "A lo largo del borde izquierdo" "Along the right" = "A lo largo del borde derecho" @@ -17,6 +19,8 @@ status = "draft — needs native review" "Between the selected layers" = "Entre las capas seleccionadas" "Check" = "Visto" "Chevron" = "Cheurón" +"Clear results" = "Borrar resultados" +"Click objects to vote; click your badge to take a vote back" = "Haz clic en los objetos para votar; haz clic en tu insignia para retirar un voto" "Compatibility notes" = "Notas de compatibilidad" "Connector" = "Conector" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -469,6 +473,7 @@ status = "draft — needs native review" "Empty" = "Vacía" "End" = "Final" "End point" = "Punto final" +"End vote" = "Terminar votación" "Ends the session; everyone keeps their copy" = "Termina la sesión; cada persona conserva su copia" "Enter" = "Intro" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Escribe la dirección de un figcraft-server y pulsa Explorar para ver los plugins que ofrece." @@ -802,6 +807,7 @@ status = "draft — needs native review" "Min H" = "Mín. H" "Min W" = "Mín. W" "Minimize" = "Minimizar" +"Minutes" = "Minutos" "Mirror angle" = "Reflejar ángulo" "Mirror angle and length" = "Reflejar ángulo y longitud" "Mirroring" = "Reflejo" @@ -899,6 +905,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Aún no hay estilos de texto. Haz clic en + para crear uno a partir del texto seleccionado." "No variables fit here." = "Ninguna variable encaja aquí." "No variables yet. Create them in Local variables." = "Aún no hay variables. Créalas en Variables locales." +"No votes yet" = "Aún no hay votos" "Nobody else is here yet" = "Aún no hay nadie más" "Noise" = "Ruido" "None" = "Ninguno" @@ -988,6 +995,7 @@ status = "draft — needs native review" "Page deleted" = "Página eliminada" "Pages" = "Páginas" "Pages added to the document" = "Páginas añadidas al documento" +"Pause" = "Pausar" "Pink" = "Rosa" "Placed on the current page" = "Colocado en la página actual" "Pages: {pages}" = "Páginas: {pages}" @@ -1175,6 +1183,8 @@ status = "draft — needs native review" "Restore main component" = "Restaurar componente principal" "Restore this version" = "Restaurar esta versión" "Restored “{name}”" = "“{name}” restaurada" +"Results" = "Resultados" +"Resume" = "Reanudar" "Reverse the order" = "Invertir el orden" "Reversed triangle" = "Triángulo invertido" "Review and merge" = "Revisar y fusionar" @@ -1357,17 +1367,20 @@ status = "draft — needs native review" "Start point" = "Punto inicial" "Start sharing" = "Empezar a compartir" "Start sharing with the Share button, then pass on the code it gives" = "Empieza a compartir con el botón Compartir y pasa el código que te da" +"Start vote" = "Iniciar votación" "Starting to share…" = "Empezando a compartir…" "Status" = "Estado" "Sticky" = "Adherido" "Sticky note" = "Nota adhesiva" "Sticky note colors" = "Colores de las notas adhesivas" +"Sticky notes" = "Notas adhesivas" "Sticky notes, shapes and connectors" = "Notas adhesivas, formas y conectores" "Stop" = "Parar" "Stop following {name}" = "Dejar de seguir a {name}" "Stop session" = "Detener sesión" "Stop sharing" = "Dejar de compartir" "Stop spotlighting" = "Salir del foco" +"Stop timer" = "Detener temporizador" "Stop {n} {hex}" = "Parada {n} {hex}" "Straight" = "Recto" "Stretch" = "Estirar" @@ -1457,6 +1470,7 @@ status = "draft — needs native review" "Tidy up" = "Ordenar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" +"Timer" = "Temporizador" "Tint" = "Matiz" "Title" = "Título" "Title case" = "Tipo título" @@ -1554,6 +1568,8 @@ status = "draft — needs native review" "Visibility" = "Visibilidad" "Visible" = "Visible" "Vivid" = "Vívido" +"Votes per person" = "Votos por persona" +"Voting session" = "Votación" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica la API gráfica que se usa; si la quitas, FigCraft elige por sí mismo." "Warm" = "Cálido" "Watch" = "Reloj" @@ -1777,6 +1793,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} capa seleccionada", other = "{n} capas seleccionadas" } "{n} layers · {parts}" = { one = "{n} capa · {parts}", other = "{n} capas · {parts}" } "{n} matches" = { one = "{n} coincidencia", other = "{n} coincidencias" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "hace {n} minuto", other = "hace {n} minutos" } "{n} months ago" = { one = "hace {n} mes", other = "hace {n} meses" } "{n} more notes omitted from this report" = { one = "{n} nota más omitida de este informe", other = "{n} notas más omitidas de este informe" } @@ -1790,6 +1807,8 @@ status = "draft — needs native review" "{n} styles" = { one = "{n} estilo", other = "{n} estilos" } "{n} text layers" = { one = "{n} capa de texto", other = "{n} capas de texto" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } +"{n} votes" = { one = "{n} voto", other = "{n} votos" } +"{n} votes left" = { one = "Te queda {n} voto", other = "Te quedan {n} votos" } "{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" } "{n} years ago" = { one = "hace {n} año", other = "hace {n} años" } "{n}d" = "{n} d" @@ -1854,6 +1873,10 @@ status = "draft — needs native review" "board.shape" = "Forma con texto" "board.stamp" = "Sello" "board.sticky" = "Nota adhesiva" +"board.timer" = "Temporizador" +"board.timerState" = "Estado del temporizador" +"board.vote" = "Votación" +"board.voteResults" = "Resultados de la votación" "bool.exclude" = "Excluir selección" "bool.flatten" = "Acoplar booleana" "bool.intersect" = "Intersecar selección" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index ec4f34a8..e6fcb424 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -38,6 +38,7 @@ status = "draft — needs native review" "Add a description" = "Lisää kuvaus" "Add a frame (F) or a shape (R) to get started" = "Aloita lisäämällä kehys (F) tai muoto (R)" "Add a layer or component to tile first" = "Lisää ensin laatoitettava taso tai komponentti" +"Add a minute" = "Lisää minuutti" "Add a note for developers" = "Lisää huomautus kehittäjille" "Add action" = "Lisää toiminto" "Add actions for when no condition holds" = "Lisää toimintoja tilanteeseen, jossa mikään ehto ei täyty" @@ -93,6 +94,7 @@ status = "draft — needs native review" "All comments" = "Kaikki kommentit" "All components" = "Kaikki komponentit" "All fonts" = "Kaikki fontit" +"All objects" = "Kaikki objektit" "All supported properties" = "Kaikki tuetut ominaisuudet" "All the fonts this file uses are installed" = "Kaikki tämän tiedoston käyttämät fontit on asennettu" "All variables" = "Kaikki muuttujat" @@ -241,8 +243,10 @@ status = "draft — needs native review" "Circle" = "Ympyrä" "Clear from this list" = "Poista tästä luettelosta" "Clear recent" = "Tyhjennä viimeisimmät" +"Clear results" = "Tyhjennä tulokset" "Click + to replace mixed content" = "Korvaa vaihteleva sisältö napsauttamalla +" "Click anywhere on the canvas to leave one." = "Lisää huomautus napsauttamalla mitä tahansa piirtoalueen kohtaa." +"Click objects to vote; click your badge to take a vote back" = "Äänestä napsauttamalla objekteja; peru ääni napsauttamalla merkkiäsi" "Click or drag to merge · {alt} removes" = "Yhdistä napsauttamalla tai vetämällä · {alt} poistaa" "Click or drag to remove · {alt} merges" = "Poista napsauttamalla tai vetämällä · {alt} yhdistää" "Click to record a key" = "Tallenna näppäin napsauttamalla" @@ -463,6 +467,7 @@ status = "draft — needs native review" "Empty" = "Tyhjä" "End" = "Loppu" "End point" = "Loppupiste" +"End vote" = "Lopeta äänestys" "Ends the session; everyone keeps their copy" = "Lopettaa istunnon. Kaikki säilyttävät oman kopionsa." "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Anna figcraft-server-palvelimen osoite ja näytä sen tarjoamat liitännäiset painamalla Selaa." @@ -792,6 +797,7 @@ status = "draft — needs native review" "Min H" = "Vähimmäiskorkeus" "Min W" = "Vähimmäisleveys" "Minimize" = "Pienennä" +"Minutes" = "Minuutit" "Mirror angle" = "Peilaa kulma" "Mirror angle and length" = "Peilaa kulma ja pituus" "Mirroring" = "Peilaus" @@ -889,6 +895,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Ei vielä tekstityylejä. Luo tyyli valitusta tekstistä napsauttamalla +." "No variables fit here." = "Tähän ei sovi muuttujia." "No variables yet. Create them in Local variables." = "Ei vielä muuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No votes yet" = "Ei vielä ääniä" "Nobody else is here yet" = "Kukaan muu ei ole vielä paikalla" "Noise" = "Kohina" "None" = "Ei mitään" @@ -996,6 +1003,7 @@ status = "draft — needs native review" "Pattern alignment" = "Kuvion tasaus" "Pattern scale" = "Kuvion skaalaus" "Pattern source" = "Kuvion lähde" +"Pause" = "Keskeytä" "Pen" = "Kynä" "Pencil" = "Lyijykynä" "People in this file" = "Tämän tiedoston käyttäjät" @@ -1158,6 +1166,8 @@ status = "draft — needs native review" "Restore main component" = "Palauta pääkomponentti" "Restore this version" = "Palauta tämä versio" "Restored “{name}”" = "”{name}” palautettu" +"Results" = "Tulokset" +"Resume" = "Jatka" "Reverse the order" = "Käännä järjestys" "Reversed triangle" = "Käänteinen kolmio" "Review and merge" = "Tarkista ja yhdistä" @@ -1337,17 +1347,20 @@ status = "draft — needs native review" "Start point" = "Alkupiste" "Start sharing" = "Aloita jakaminen" "Start sharing with the Share button, then pass on the code it gives" = "Aloita jakaminen Jaa-painikkeella ja välitä sen antama koodi" +"Start vote" = "Aloita äänestys" "Starting to share…" = "Aloitetaan jakamista…" "Status" = "Tila" "Sticky" = "Kiinnittyvä" "Sticky note" = "Muistilappu" "Sticky note colors" = "Muistilappujen värit" +"Sticky notes" = "Muistilaput" "Sticky notes, shapes and connectors" = "Muistilaput, muodot ja yhdistimet" "Stop" = "Pysäytä" "Stop following {name}" = "Lopeta käyttäjän {name} seuraaminen" "Stop session" = "Lopeta istunto" "Stop sharing" = "Lopeta jakaminen" "Stop spotlighting" = "Lopeta esiin nostaminen" +"Stop timer" = "Pysäytä ajastin" "Stop {n} {hex}" = "Piste {n} {hex}" "Straight" = "Suora" "Stretch" = "Venytys" @@ -1433,6 +1446,7 @@ status = "draft — needs native review" "Tidy up" = "Järjestä tasavälein" "Tile" = "Laatoitus" "Tile type" = "Laatan tyyppi" +"Timer" = "Ajastin" "Tint" = "Sävytys" "Title" = "Otsikko" "Title case" = "Sanojen isot alkukirjaimet" @@ -1528,6 +1542,8 @@ status = "draft — needs native review" "Visibility" = "Näkyvyys" "Visible" = "Näkyvä" "Vivid" = "Kirkas" +"Votes per person" = "Ääniä henkilöä kohden" +"Voting session" = "Äänestys" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND on asetettu ja määrittää käytettävän grafiikkarajapinnan. Poista se käytöstä, jotta FigCraft voi valita." "Warm" = "Lämmin" "Watch" = "Seuraa" @@ -1747,6 +1763,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} taso valittu", other = "{n} tasoa valittu" } "{n} layers · {parts}" = { one = "{n} taso · {parts}", other = "{n} tasoa · {parts}" } "{n} matches" = { one = "{n} osuma", other = "{n} osumaa" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "{n} minuutti sitten", other = "{n} minuuttia sitten" } "{n} months ago" = { one = "{n} kuukausi sitten", other = "{n} kuukautta sitten" } "{n} more notes omitted from this report" = { one = "Tästä raportista on jätetty pois vielä {n} huomautus", other = "Tästä raportista on jätetty pois vielä {n} huomautusta" } @@ -1760,6 +1777,8 @@ status = "draft — needs native review" "{n} styles" = { one = "{n} tyyli", other = "{n} tyyliä" } "{n} text layers" = { one = "{n} tekstitaso", other = "{n} tekstitasoa" } "{n} variables" = { one = "{n} muuttuja", other = "{n} muuttujaa" } +"{n} votes" = { one = "{n} ääni", other = "{n} ääntä" } +"{n} votes left" = { one = "{n} ääni jäljellä", other = "{n} ääntä jäljellä" } "{n} weeks ago" = { one = "{n} viikko sitten", other = "{n} viikkoa sitten" } "{n} years ago" = { one = "{n} vuosi sitten", other = "{n} vuotta sitten" } "{n}d" = "{n}pv" @@ -1822,6 +1841,10 @@ status = "draft — needs native review" "board.shape" = "Muoto ja teksti" "board.stamp" = "Leima" "board.sticky" = "Muistilappu" +"board.timer" = "Ajastin" +"board.timerState" = "Ajastimen tila" +"board.vote" = "Äänestys" +"board.voteResults" = "Äänestyksen tulokset" "bool.exclude" = "Sulje valinnan päällekkäinen alue pois" "bool.flatten" = "Yhdistä Boolen muoto vektoriksi" "bool.intersect" = "Leikkaa valinnan yhteinen alue" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 984c667c..bf98f8dc 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -37,6 +37,7 @@ status = "draft — needs native review" "Add a description" = "Ajouter une description" "Add a frame (F) or a shape (R) to get started" = "Ajoutez un cadre (F) ou une forme (R) pour commencer" "Add a layer or component to tile first" = "Ajoutez d’abord un calque ou un composant à répéter en mosaïque" +"Add a minute" = "Ajouter une minute" "Add a note for developers" = "Ajouter une note pour les développeurs" "Add action" = "Ajouter une action" "Add actions for when no condition holds" = "Ajouter des actions pour le cas où aucune condition n’est remplie" @@ -92,6 +93,7 @@ status = "draft — needs native review" "All comments" = "Tous les commentaires" "All components" = "Tous les composants" "All fonts" = "Toutes les polices" +"All objects" = "Tous les objets" "All supported properties" = "Toutes les propriétés prises en charge" "All variables" = "Toutes les variables" "Along the bottom" = "Le long du bord inférieur" @@ -209,6 +211,8 @@ status = "draft — needs native review" "Checking the server…" = "Vérification auprès du serveur…" "Chevron" = "Chevron" "Clear from this list" = "Effacer de cette liste" +"Clear results" = "Effacer les résultats" +"Click objects to vote; click your badge to take a vote back" = "Cliquez sur des objets pour voter ; cliquez sur votre badge pour retirer un vote" "Code or invite link" = "Code ou lien d’invitation" "Color profile: {name}" = "Profil colorimétrique : {name}" "CSS custom properties…" = "Propriétés personnalisées CSS…" @@ -462,6 +466,7 @@ status = "draft — needs native review" "Empty" = "Vide" "End" = "Fin" "End point" = "Point final" +"End vote" = "Terminer le vote" "Ends the session; everyone keeps their copy" = "Met fin à la session ; chacun conserve sa copie" "Enter" = "Entrée" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Saisissez l’adresse d’un figcraft-server et appuyez sur Parcourir pour voir les plugins qu’il propose." @@ -792,6 +797,7 @@ status = "draft — needs native review" "Min H" = "H min." "Min W" = "L min." "Minimize" = "Réduire" +"Minutes" = "Minutes" "Mirror angle" = "Symétrie d’angle" "Mirror angle and length" = "Symétrie d’angle et de longueur" "Mirroring" = "Symétrie" @@ -887,6 +893,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Aucun style de texte pour l’instant. Cliquez sur + pour en créer un à partir du texte sélectionné." "No variables fit here." = "Aucune variable ne convient ici." "No variables yet. Create them in Local variables." = "Aucune variable pour l’instant. Créez-en dans Variables locales." +"No votes yet" = "Aucun vote pour l’instant" "Nobody else is here yet" = "Personne d’autre n’est encore là" "Noise" = "Bruit" "None" = "Aucun" @@ -976,6 +983,7 @@ status = "draft — needs native review" "Page deleted" = "Page supprimée" "Pages" = "Pages" "Pages added to the document" = "Pages ajoutées au document" +"Pause" = "Pause" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" "Pink" = "Rose" "Placed on the current page" = "Placé sur la page actuelle" @@ -1159,6 +1167,8 @@ status = "draft — needs native review" "Restore main component" = "Restaurer le composant principal" "Restore this version" = "Restaurer cette version" "Restored “{name}”" = "« {name} » restaurée" +"Results" = "Résultats" +"Resume" = "Reprendre" "Reverse the order" = "Inverser l’ordre" "Reversed triangle" = "Triangle inversé" "Review and merge" = "Relire et fusionner" @@ -1336,17 +1346,20 @@ status = "draft — needs native review" "Start point" = "Point de départ" "Start sharing" = "Commencer le partage" "Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne" +"Start vote" = "Lancer le vote" "Starting to share…" = "Démarrage du partage…" "Status" = "Statut" "Sticky" = "Collant" "Sticky note" = "Note autocollante" "Sticky note colors" = "Couleurs des notes autocollantes" +"Sticky notes" = "Notes autocollantes" "Sticky notes, shapes and connectors" = "Notes autocollantes, formes et connecteurs" "Stop" = "Arrêter" "Stop following {name}" = "Ne plus suivre {name}" "Stop session" = "Arrêter la session" "Stop sharing" = "Arrêter le partage" "Stop spotlighting" = "Arrêter de faire suivre ma vue" +"Stop timer" = "Arrêter le minuteur" "Stop {n} {hex}" = "Arrêt {n} {hex}" "Straight" = "Droit" "Stretch" = "Étirer" @@ -1434,6 +1447,7 @@ status = "draft — needs native review" "Tidy up" = "Ranger" "Tile" = "Mosaïque" "Tile type" = "Type de mosaïque" +"Timer" = "Minuteur" "Tint" = "Nuance" "Title" = "Titre" "Title case" = "Casse de titre" @@ -1529,6 +1543,8 @@ status = "draft — needs native review" "Visibility" = "Visibilité" "Visible" = "Visible" "Vivid" = "Vif" +"Votes per person" = "Votes par personne" +"Voting session" = "Session de vote" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND est définie et impose l’API graphique ; sans elle, FigCraft choisit lui-même." "Warm" = "Chaud" "Watch" = "Montre" @@ -1748,6 +1764,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} calque sélectionné", other = "{n} calques sélectionnés" } "{n} layers · {parts}" = { one = "{n} calque · {parts}", other = "{n} calques · {parts}" } "{n} matches" = { one = "{n} résultat", other = "{n} résultats" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "il y a {n} minute", other = "il y a {n} minutes" } "{n} months ago" = { one = "il y a {n} mois", other = "il y a {n} mois" } "{n} more notes omitted from this report" = { one = "{n} autre note omise de ce rapport", other = "{n} autres notes omises de ce rapport" } @@ -1760,6 +1777,8 @@ status = "draft — needs native review" "{n} rows" = { one = "{n} ligne", other = "{n} lignes" } "{n} styles" = { one = "{n} style", other = "{n} styles" } "{n} variables" = { one = "{n} variable", other = "{n} variables" } +"{n} votes" = { one = "{n} vote", other = "{n} votes" } +"{n} votes left" = { one = "{n} vote restant", other = "{n} votes restants" } "{n} weeks ago" = { one = "il y a {n} semaine", other = "il y a {n} semaines" } "{n} years ago" = { one = "il y a {n} an", other = "il y a {n} ans" } "{n}d" = "{n} j" @@ -1823,6 +1842,10 @@ status = "draft — needs native review" "board.shape" = "Forme avec texte" "board.stamp" = "Tampon" "board.sticky" = "Note autocollante" +"board.timer" = "Minuteur" +"board.timerState" = "État du minuteur" +"board.vote" = "Session de vote" +"board.voteResults" = "Résultats du vote" "bool.exclude" = "Exclure la sélection" "bool.flatten" = "Aplatir le booléen" "bool.intersect" = "Intersection de la sélection" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index d007cd3b..b21fd921 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -37,6 +37,7 @@ status = "draft — needs native review" "Add a description" = "Tambah deskripsi" "Add a frame (F) or a shape (R) to get started" = "Tambahkan frame (F) atau bentuk (R) untuk memulai" "Add a layer or component to tile first" = "Tambahkan dulu layer atau component untuk dijadikan ubin" +"Add a minute" = "Tambah satu menit" "Add a note for developers" = "Tambah catatan untuk developer" "Add action" = "Tambah aksi" "Add actions for when no condition holds" = "Tambah aksi saat tidak ada kondisi yang terpenuhi" @@ -91,6 +92,7 @@ status = "draft — needs native review" "All comments" = "Semua komentar" "All components" = "Semua component" "All fonts" = "Semua font" +"All objects" = "Semua objek" "All supported properties" = "Semua properti yang didukung" "All variables" = "Semua variabel" "Along the bottom" = "Sepanjang sisi bawah" @@ -208,6 +210,8 @@ status = "draft — needs native review" "Checking the server…" = "Memeriksa server…" "Chevron" = "Chevron" "Clear from this list" = "Hapus dari daftar ini" +"Clear results" = "Hapus hasil" +"Click objects to vote; click your badge to take a vote back" = "Klik objek untuk memberi suara; klik lencana Anda untuk menarik suara" "Code or invite link" = "Kode atau link undangan" "Color profile: {name}" = "Profil warna: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Gaya warna dan teks, variabel Light dan Dark, tombol, input, dan kartu" @@ -452,6 +456,7 @@ status = "draft — needs native review" "Empty" = "Kosong" "End" = "Akhir" "End point" = "Titik akhir" +"End vote" = "Akhiri pemungutan suara" "Ends the session; everyone keeps their copy" = "Mengakhiri sesi; semua orang tetap memiliki salinannya" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Masukkan alamat figcraft-server lalu tekan Jelajahi untuk melihat plugin yang ditawarkannya." @@ -779,6 +784,7 @@ status = "draft — needs native review" "Min H" = "Min T" "Min W" = "Min L" "Minimize" = "Minimalkan" +"Minutes" = "Menit" "Mirror angle" = "Cerminkan sudut" "Mirror angle and length" = "Cerminkan sudut dan panjang" "Mirroring" = "Pencerminan" @@ -873,6 +879,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Belum ada gaya teks. Klik + untuk membuat dari teks yang dipilih." "No variables fit here." = "Tidak ada variabel yang sesuai di sini." "No variables yet. Create them in Local variables." = "Belum ada variabel. Buat di Variabel lokal." +"No votes yet" = "Belum ada suara" "Nobody else is here yet" = "Belum ada orang lain di sini" "Noise" = "Noise" "None" = "Tidak ada" @@ -958,6 +965,7 @@ status = "draft — needs native review" "Page deleted" = "Halaman dihapus" "Pages" = "Halaman" "Pages added to the document" = "Halaman ditambahkan ke dokumen" +"Pause" = "Jeda" "Pink" = "Merah muda" "Placed on the current page" = "Ditempatkan di halaman saat ini" "Pages: {pages}" = "Halaman: {pages}" @@ -1136,6 +1144,8 @@ status = "draft — needs native review" "Restore main component" = "Pulihkan main component" "Restore this version" = "Pulihkan versi ini" "Restored “{name}”" = "“{name}” dipulihkan" +"Results" = "Hasil" +"Resume" = "Lanjutkan" "Reverse the order" = "Balik urutan" "Reversed triangle" = "Segitiga terbalik" "Review and merge" = "Tinjau dan merge" @@ -1311,17 +1321,20 @@ status = "draft — needs native review" "Start point" = "Titik awal" "Start sharing" = "Mulai berbagi" "Start sharing with the Share button, then pass on the code it gives" = "Mulai berbagi dengan tombol Bagikan, lalu teruskan kode yang diberikannya" +"Start vote" = "Mulai pemungutan suara" "Starting to share…" = "Memulai berbagi…" "Status" = "Status" "Sticky" = "Menempel" "Sticky note" = "Catatan tempel" "Sticky note colors" = "Warna catatan tempel" +"Sticky notes" = "Catatan tempel" "Sticky notes, shapes and connectors" = "Catatan tempel, bentuk, dan konektor" "Stop" = "Hentikan" "Stop following {name}" = "Berhenti mengikuti {name}" "Stop session" = "Hentikan sesi" "Stop sharing" = "Berhenti berbagi" "Stop spotlighting" = "Hentikan sorotan" +"Stop timer" = "Hentikan pengatur waktu" "Stop {n} {hex}" = "Titik {n} {hex}" "Straight" = "Lurus" "Stretch" = "Regangkan" @@ -1403,6 +1416,7 @@ status = "draft — needs native review" "Tidy up" = "Rapikan" "Tile" = "Ubin" "Tile type" = "Jenis ubin" +"Timer" = "Pengatur waktu" "Tint" = "Rona" "Title" = "Judul" "Title case" = "Huruf Awal Kapital" @@ -1495,6 +1509,8 @@ status = "draft — needs native review" "Visibility" = "Visibilitas" "Visible" = "Terlihat" "Vivid" = "Cerah" +"Votes per person" = "Suara per orang" +"Voting session" = "Sesi pemungutan suara" "Warm" = "Hangat" "Watch" = "Jam tangan" "Wavy" = "Bergelombang" @@ -1710,6 +1726,7 @@ status = "draft — needs native review" "{n} layers selected" = { other = "{n} layer dipilih" } "{n} layers · {parts}" = { other = "{n} layer · {parts}" } "{n} matches" = { other = "{n} kecocokan" } +"{n} min" = "{n} mnt" "{n} minutes ago" = { other = "{n} menit lalu" } "{n} months ago" = { other = "{n} bulan lalu" } "{n} more notes omitted from this report" = { other = "{n} catatan lainnya tidak dicantumkan dalam laporan ini" } @@ -1722,6 +1739,8 @@ status = "draft — needs native review" "{n} rows" = { other = "{n} baris" } "{n} styles" = { other = "{n} gaya" } "{n} variables" = { other = "{n} variabel" } +"{n} votes" = { other = "{n} suara" } +"{n} votes left" = { other = "Sisa {n} suara" } "{n} weeks ago" = { other = "{n} minggu lalu" } "{n} years ago" = { other = "{n} tahun lalu" } "{n}d" = "{n} hr" @@ -1784,6 +1803,10 @@ status = "draft — needs native review" "board.shape" = "Bentuk dengan teks" "board.stamp" = "Stempel" "board.sticky" = "Catatan tempel" +"board.timer" = "Pengatur waktu" +"board.timerState" = "Status pengatur waktu" +"board.vote" = "Sesi pemungutan suara" +"board.voteResults" = "Hasil pemungutan suara" "bool.exclude" = "Kecualikan pilihan" "bool.flatten" = "Satukan boolean" "bool.intersect" = "Iriskan pilihan" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index dc0a0e3b..8234720f 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -39,6 +39,7 @@ status = "draft — needs native review" "Add a description" = "説明を追加" "Add a frame (F) or a shape (R) to get started" = "フレーム(F)またはシェイプ(R)を追加して開始" "Add a layer or component to tile first" = "先に並べるレイヤーまたはコンポーネントを追加" +"Add a minute" = "1分追加" "Add a note for developers" = "開発者向けのメモを追加" "Add action" = "アクションを追加" "Add actions for when no condition holds" = "どの条件にも当てはまらないときのアクションを追加" @@ -93,6 +94,7 @@ status = "draft — needs native review" "All comments" = "すべてのコメント" "All components" = "すべてのコンポーネント" "All fonts" = "すべてのフォント" +"All objects" = "すべてのオブジェクト" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" "Along the bottom" = "下辺に沿って" @@ -210,6 +212,8 @@ status = "draft — needs native review" "Checking the server…" = "サーバーを確認中…" "Chevron" = "シェブロン" "Clear from this list" = "このリストから消去" +"Clear results" = "結果をクリア" +"Click objects to vote; click your badge to take a vote back" = "オブジェクトをクリックして投票、自分のバッジをクリックして票を取り消し" "Code or invite link" = "コードまたは招待リンク" "Color profile: {name}" = "カラープロファイル:{name}" "CSS custom properties…" = "CSS カスタムプロパティ…" @@ -457,6 +461,7 @@ status = "draft — needs native review" "Empty" = "空" "End" = "終了" "End point" = "終点" +"End vote" = "投票を終了" "Ends the session; everyone keeps their copy" = "セッションを終了。各自のコピーは残る" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "figcraft-server のアドレスを入力し、「参照」を押すと、提供されているプラグインが表示される。" @@ -786,6 +791,7 @@ status = "draft — needs native review" "Min H" = "最小 H" "Min W" = "最小 W" "Minimize" = "最小化" +"Minutes" = "分" "Mirror angle" = "角度を対称にする" "Mirror angle and length" = "角度と長さを対称にする" "Mirroring" = "対称" @@ -880,6 +886,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "テキストスタイルはまだない。+ をクリックして選択したテキストから作成。" "No variables fit here." = "ここに使える変数はない。" "No variables yet. Create them in Local variables." = "変数はまだない。ローカル変数で作成。" +"No votes yet" = "まだ投票はありません" "Nobody else is here yet" = "まだ他に誰もいない" "Noise" = "ノイズ" "None" = "なし" @@ -966,6 +973,7 @@ status = "draft — needs native review" "Page deleted" = "ページは削除済み" "Pages" = "ページ" "Pages added to the document" = "ページをドキュメントに追加した" +"Pause" = "一時停止" "Pink" = "ピンク" "Placed on the current page" = "現在のページに配置した" "Pages: {pages}" = "ページ:{pages}" @@ -1146,6 +1154,8 @@ status = "draft — needs native review" "Restore main component" = "メインコンポーネントを復元" "Restore this version" = "このバージョンを復元" "Restored “{name}”" = "「{name}」を復元済み" +"Results" = "結果" +"Resume" = "再開" "Reverse the order" = "順序を反転" "Reversed triangle" = "逆三角形" "Review and merge" = "レビューしてマージ" @@ -1323,17 +1333,20 @@ status = "draft — needs native review" "Start point" = "始点" "Start sharing" = "共有を開始" "Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える" +"Start vote" = "投票を開始" "Starting to share…" = "共有を開始中…" "Status" = "ステータス" "Sticky" = "固定" "Sticky note" = "付箋" "Sticky note colors" = "付箋の色" +"Sticky notes" = "付箋" "Sticky notes, shapes and connectors" = "付箋、シェイプ、コネクタ" "Stop" = "停止" "Stop following {name}" = "{name} のフォローを停止" "Stop session" = "セッションを停止" "Stop sharing" = "共有を停止" "Stop spotlighting" = "スポットライトを停止" +"Stop timer" = "タイマーを停止" "Stop {n} {hex}" = "分岐点 {n} {hex}" "Straight" = "直線" "Stretch" = "伸縮" @@ -1417,6 +1430,7 @@ status = "draft — needs native review" "Tidy up" = "整列" "Tile" = "タイル" "Tile type" = "タイルの種類" +"Timer" = "タイマー" "Tint" = "ティント" "Title" = "タイトル" "Title case" = "タイトルケース" @@ -1511,6 +1525,8 @@ status = "draft — needs native review" "Visibility" = "表示" "Visible" = "表示" "Vivid" = "ビビッド" +"Votes per person" = "1人あたりの票数" +"Voting session" = "投票セッション" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND が設定されていて、使うグラフィックス API を指定している。解除すると FigCraft が選ぶ。" "Warm" = "ウォーム" "Watch" = "ウォッチ" @@ -1729,6 +1745,7 @@ status = "draft — needs native review" "{n} layers selected" = { other = "{n} 個のレイヤーを選択中" } "{n} layers · {parts}" = { other = "{n} 個のレイヤー · {parts}" } "{n} matches" = { other = "{n} 件一致" } +"{n} min" = "{n}分" "{n} minutes ago" = { other = "{n} 分前" } "{n} months ago" = { other = "{n} か月前" } "{n} more notes omitted from this report" = { other = "このレポートで省略した注記がさらに {n} 件" } @@ -1741,6 +1758,8 @@ status = "draft — needs native review" "{n} rows" = { other = "{n} 行" } "{n} styles" = { other = "{n} 個のスタイル" } "{n} variables" = { other = "{n} 個の変数" } +"{n} votes" = { other = "{n}票" } +"{n} votes left" = { other = "残り{n}票" } "{n} weeks ago" = { other = "{n} 週間前" } "{n} years ago" = { other = "{n} 年前" } "{n}d" = "{n}日" @@ -1803,6 +1822,10 @@ status = "draft — needs native review" "board.shape" = "テキスト付きシェイプ" "board.stamp" = "スタンプ" "board.sticky" = "付箋" +"board.timer" = "タイマー" +"board.timerState" = "タイマーの状態" +"board.vote" = "投票セッション" +"board.voteResults" = "投票結果" "bool.exclude" = "選択範囲を除外" "bool.flatten" = "ブーリアンを平坦化" "bool.intersect" = "選択範囲を交差" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 0cab4030..c5f73101 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -39,6 +39,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add a description" = "აღწერის დამატება" "Add a frame (F) or a shape (R) to get started" = "დასაწყებად დაამატეთ ჩარჩო (F) ან ფიგურა (R)" "Add a layer or component to tile first" = "ჯერ დაამატეთ შრე ან კომპონენტი ფილებად დასაწყობად" +"Add a minute" = "ერთი წუთის დამატება" "Add a note for developers" = "შენიშვნის დამატება დეველოპერებისთვის" "Add action" = "მოქმედების დამატება" "Add actions for when no condition holds" = "მოქმედებების დამატება იმ შემთხვევისთვის, როცა არცერთი პირობა არ სრულდება" @@ -94,6 +95,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "All comments" = "ყველა კომენტარი" "All components" = "ყველა კომპონენტი" "All fonts" = "ყველა შრიფტი" +"All objects" = "ყველა ობიექტი" "All supported properties" = "ყველა მხარდაჭერილი თვისება" "All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" "All variables" = "ყველა ცვლადი" @@ -213,6 +215,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Checking the server…" = "სერვერის შემოწმება…" "Chevron" = "შევრონი" "Clear from this list" = "ამ სიიდან ამოღება" +"Clear results" = "შედეგების გასუფთავება" +"Click objects to vote; click your badge to take a vote back" = "ხმის მისაცემად დააწკაპუნეთ ობიექტებზე; ხმის დასაბრუნებლად დააწკაპუნეთ თქვენს ნიშანზე" "Code or invite link" = "კოდი ან მოსაწვევი ბმული" "Color profile: {name}" = "ფერის პროფილი: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "სტილები, ღია და მუქი ცვლადები, ღილაკები და ბარათები" @@ -463,6 +467,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Empty" = "ცარიელი" "End" = "ბოლო" "End point" = "საბოლოო წერტილი" +"End vote" = "ხმის მიცემის დასრულება" "Ends the session; everyone keeps their copy" = "ასრულებს სესიას; ყველას თავისი ასლი რჩება" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "შეიყვანეთ figcraft-server-ის მისამართი და დააჭირეთ „დათვალიერებას“, რომ ნახოთ მის მიერ შემოთავაზებული პლაგინები." @@ -792,6 +797,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Min H" = "მინ. H" "Min W" = "მინ. W" "Minimize" = "ჩაკეცვა" +"Minutes" = "წუთები" "Mirror angle" = "კუთხის არეკვლა" "Mirror angle and length" = "კუთხისა და სიგრძის არეკვლა" "Mirroring" = "არეკვლა" @@ -889,6 +895,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "No text styles yet. Click + to create one from the selected text." = "ტექსტის სტილები ჯერ არ არის. დაწკაპეთ +, რომ მონიშნული ტექსტიდან შექმნათ." "No variables fit here." = "აქ არცერთი ცვლადი არ ერგება." "No variables yet. Create them in Local variables." = "ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No votes yet" = "ხმები ჯერ არ არის" "Nobody else is here yet" = "აქ ჯერ სხვა არავინ არის" "Noise" = "ხმაური" "None" = "არცერთი" @@ -975,6 +982,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Page" = "გვერდი" "Pages" = "გვერდები" "Pages added to the document" = "გვერდები დაემატა დოკუმენტს" +"Pause" = "პაუზა" "Pink" = "ვარდისფერი" "Placed on the current page" = "განთავსდა მიმდინარე გვერდზე" "Pages: {pages}" = "გვერდი: {pages}" @@ -1156,6 +1164,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Restore main component" = "მთავარი კომპონენტის აღდგენა" "Restore this version" = "ამ ვერსიის აღდგენა" "Restored “{name}”" = "„{name}“ აღდგა" +"Results" = "შედეგები" +"Resume" = "გაგრძელება" "Reverse the order" = "რიგის შებრუნება" "Reversed triangle" = "შებრუნებული სამკუთხედი" "Review and merge" = "განხილვა და შერწყმა" @@ -1334,17 +1344,20 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Start point" = "საწყისი წერტილი" "Start sharing" = "გაზიარების დაწყება" "Start sharing with the Share button, then pass on the code it gives" = "დააჭირეთ „გაზიარებას“ და გადაეცით მიღებული კოდი" +"Start vote" = "ხმის მიცემის დაწყება" "Starting to share…" = "გაზიარების დაწყება…" "Status" = "სტატუსი" "Sticky" = "მიმაგრებული" "Sticky note" = "სტიკერი" "Sticky note colors" = "სტიკერების ფერები" +"Sticky notes" = "სტიკერები" "Sticky notes, shapes and connectors" = "სტიკერები, ფიგურები და შემაერთებლები" "Stop" = "შეწყვეტა" "Stop following {name}" = "{name}-ის მიყოლის შეწყვეტა" "Stop session" = "სესიის შეწყვეტა" "Stop sharing" = "გაზიარების შეწყვეტა" "Stop spotlighting" = "ჩემი ხედის ჩვენების შეწყვეტა" +"Stop timer" = "ტაიმერის გაჩერება" "Stop {n} {hex}" = "გადასვლის წერტილი {n} {hex}" "Straight" = "სწორი" "Stretch" = "გაწელვა" @@ -1430,6 +1443,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Tidy up" = "მოწესრიგება" "Tile" = "ფილებად" "Tile type" = "ფილის ტიპი" +"Timer" = "ტაიმერი" "Tint" = "ტონირება" "Title" = "დასახელება" "Title case" = "სათაურის რეგისტრი" @@ -1525,6 +1539,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Visibility" = "ხილვადობა" "Visible" = "ხილული" "Vivid" = "ცოცხალი" +"Votes per person" = "ხმები ერთ ადამიანზე" +"Voting session" = "ხმის მიცემის სესია" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND დაყენებულია და გამოსაყენებელ გრაფიკულ API-ს განსაზღვრავს; მისი მოხსნის შემდეგ FigCraft თავად აირჩევს." "Warm" = "თბილი" "Watch" = "საათი" @@ -1743,6 +1759,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "{n} layers selected" = { one = "მონიშნული შრე: {n}", other = "მონიშნული შრე: {n}" } "{n} layers · {parts}" = { one = "შრე: {n} · {parts}", other = "შრე: {n} · {parts}" } "{n} matches" = { one = "დამთხვევა: {n}", other = "დამთხვევა: {n}" } +"{n} min" = "{n} წთ" "{n} minutes ago" = { one = "{n} წუთის წინ", other = "{n} წუთის წინ" } "{n} months ago" = { one = "{n} თვის წინ", other = "{n} თვის წინ" } "{n} more notes omitted from this report" = { one = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}", other = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}" } @@ -1756,6 +1773,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "{n} styles" = { one = "სტილი: {n}", other = "სტილი: {n}" } "{n} text layers" = { one = "ტექსტის შრე: {n}", other = "ტექსტის შრე: {n}" } "{n} variables" = { one = "ცვლადი: {n}", other = "ცვლადი: {n}" } +"{n} votes" = { one = "ხმა: {n}", other = "ხმა: {n}" } +"{n} votes left" = { one = "დარჩენილი ხმა: {n}", other = "დარჩენილი ხმა: {n}" } "{n} weeks ago" = { one = "{n} კვირის წინ", other = "{n} კვირის წინ" } "{n} years ago" = { one = "{n} წლის წინ", other = "{n} წლის წინ" } "{n}d" = "{n} დღ" @@ -1818,6 +1837,10 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "board.shape" = "ფიგურა ტექსტით" "board.stamp" = "შტამპი" "board.sticky" = "სტიკერი" +"board.timer" = "ტაიმერი" +"board.timerState" = "ტაიმერის მდგომარეობა" +"board.vote" = "ხმის მიცემის სესია" +"board.voteResults" = "ხმის მიცემის შედეგები" "bool.exclude" = "მონიშნულის გამორიცხვა" "bool.flatten" = "ლოგიკური ოპერაციის გაბრტყელება" "bool.intersect" = "მონიშნულის გადაკვეთა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index e2c88d32..86073183 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Add a description" = "설명 추가" "Add a frame (F) or a shape (R) to get started" = "프레임(F) 또는 도형(R)을 추가해 시작하세요" "Add a layer or component to tile first" = "먼저 바둑판식으로 배열할 레이어나 컴포넌트를 추가하세요" +"Add a minute" = "1분 추가" "Add a note for developers" = "개발자용 메모 추가" "Add action" = "액션 추가" "Add actions for when no condition holds" = "조건에 맞지 않을 때의 액션 추가" @@ -89,6 +90,7 @@ status = "draft — needs native review" "All comments" = "모든 댓글" "All components" = "모든 컴포넌트" "All fonts" = "모든 글꼴" +"All objects" = "모든 개체" "All supported properties" = "지원되는 모든 속성" "All variables" = "모든 변수" "Along the bottom" = "아래쪽 가장자리를 따라" @@ -200,6 +202,8 @@ status = "draft — needs native review" "Checking the server…" = "서버 확인 중…" "Chevron" = "셰브런" "Clear from this list" = "이 목록에서 지우기" +"Clear results" = "결과 지우기" +"Click objects to vote; click your badge to take a vote back" = "개체를 클릭해 투표하고, 내 배지를 클릭해 투표를 취소하세요" "Code or invite link" = "코드 또는 초대 링크" "Color profile: {name}" = "색상 프로필: {name}" "CSS custom properties…" = "CSS 사용자 정의 속성…" @@ -439,6 +443,7 @@ status = "draft — needs native review" "Empty" = "비어 있음" "End" = "끝" "End point" = "끝점" +"End vote" = "투표 종료" "Ends the session; everyone keeps their copy" = "세션을 종료함. 각자의 사본은 그대로 유지됨" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "figcraft-server 주소를 입력하고 찾아보기를 눌러 제공되는 플러그인을 확인하세요." @@ -748,6 +753,7 @@ status = "draft — needs native review" "Min H" = "최소 H" "Min W" = "최소 W" "Minimize" = "최소화" +"Minutes" = "분" "Mirror angle" = "각도 대칭" "Mirror angle and length" = "각도와 길이 대칭" "Mirroring" = "대칭" @@ -839,6 +845,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "아직 텍스트 스타일이 없음. +를 클릭해 선택한 텍스트로 만드세요." "No variables fit here." = "여기에 맞는 변수가 없음." "No variables yet. Create them in Local variables." = "아직 변수가 없음. 로컬 변수에서 만드세요." +"No votes yet" = "아직 투표가 없습니다" "Nobody else is here yet" = "아직 다른 사람이 없음" "Noise" = "노이즈" "None" = "없음" @@ -920,6 +927,7 @@ status = "draft — needs native review" "Page deleted" = "페이지가 삭제됨" "Pages" = "페이지" "Pages added to the document" = "문서에 페이지 추가됨" +"Pause" = "일시 정지" "Pink" = "분홍" "Placed on the current page" = "현재 페이지에 배치됨" "Pages: {pages}" = "페이지: {pages}" @@ -1096,6 +1104,8 @@ status = "draft — needs native review" "Restore main component" = "메인 컴포넌트 복원" "Restore this version" = "이 버전 복원" "Restored “{name}”" = "\"{name}\" 복원됨" +"Results" = "결과" +"Resume" = "재개" "Reverse the order" = "순서 뒤집기" "Reversed triangle" = "역삼각형" "Review and merge" = "검토 및 병합" @@ -1258,17 +1268,20 @@ status = "draft — needs native review" "Start on the Home screen" = "홈에서 시작" "Start point" = "시작점" "Start sharing" = "공유 시작" +"Start vote" = "투표 시작" "Starting to share…" = "공유를 시작하는 중…" "Status" = "상태" "Sticky" = "고정" "Sticky note" = "스티커 메모" "Sticky note colors" = "스티커 메모 색상" +"Sticky notes" = "스티커 메모" "Sticky notes, shapes and connectors" = "스티커 메모, 도형, 커넥터" "Stop" = "중지" "Stop following {name}" = "{name} 따라가기 중지" "Stop session" = "세션 중지" "Stop sharing" = "공유 중지" "Stop spotlighting" = "스포트라이트 중지" +"Stop timer" = "타이머 중지" "Stop {n} {hex}" = "정지점 {n} {hex}" "Straight" = "직선" "Stretch" = "늘이기" @@ -1351,6 +1364,7 @@ status = "draft — needs native review" "Tidy up" = "정돈" "Tile" = "타일" "Tile type" = "타일 유형" +"Timer" = "타이머" "Tint" = "색조 보정" "Title" = "제목" "Title case" = "제목 대소문자" @@ -1442,6 +1456,8 @@ status = "draft — needs native review" "Visibility" = "표시 여부" "Visible" = "표시됨" "Vivid" = "선명하게" +"Votes per person" = "1인당 투표 수" +"Voting session" = "투표 세션" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND가 설정되어 사용할 그래픽 API를 지정하고 있습니다. 설정을 해제하면 FigCraft가 선택합니다." "Warm" = "따뜻하게" "Watch" = "시계" @@ -1653,6 +1669,7 @@ status = "draft — needs native review" "{n} layers selected" = { other = "레이어 {n}개 선택됨" } "{n} layers · {parts}" = { other = "레이어 {n}개 · {parts}" } "{n} matches" = { other = "{n}개 일치" } +"{n} min" = "{n}분" "{n} minutes ago" = { other = "{n}분 전" } "{n} months ago" = { other = "{n}개월 전" } "{n} more notes omitted from this report" = { other = "참고 사항 {n}개가 이 보고서에서 더 생략됨" } @@ -1665,6 +1682,8 @@ status = "draft — needs native review" "{n} rows" = { other = "{n}행" } "{n} styles" = { other = "스타일 {n}개" } "{n} variables" = { other = "변수 {n}개" } +"{n} votes" = { other = "{n}표" } +"{n} votes left" = { other = "남은 투표 {n}개" } "{n} weeks ago" = { other = "{n}주 전" } "{n} years ago" = { other = "{n}년 전" } "{n}d" = "{n}일" @@ -1727,6 +1746,10 @@ status = "draft — needs native review" "board.shape" = "텍스트가 있는 도형" "board.stamp" = "스탬프" "board.sticky" = "스티커 메모" +"board.timer" = "타이머" +"board.timerState" = "타이머 상태" +"board.vote" = "투표 세션" +"board.voteResults" = "투표 결과" "bool.exclude" = "선택 항목 제외" "bool.flatten" = "불리언 평면화" "bool.intersect" = "선택 항목 교차" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index c25775f6..c36b7a30 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -38,6 +38,7 @@ status = "draft — needs native review" "Add a description" = "Beschrijving toevoegen" "Add a frame (F) or a shape (R) to get started" = "Voeg een frame (F) of een vorm (R) toe om te beginnen" "Add a layer or component to tile first" = "Voeg eerst een laag of component toe om als tegel te gebruiken" +"Add a minute" = "Een minuut toevoegen" "Add a note for developers" = "Notitie voor ontwikkelaars toevoegen" "Add action" = "Actie toevoegen" "Add actions for when no condition holds" = "Voeg acties toe voor het geval geen enkele voorwaarde geldt" @@ -93,6 +94,7 @@ status = "draft — needs native review" "All comments" = "Alle reacties" "All components" = "Alle components" "All fonts" = "Alle lettertypen" +"All objects" = "Alle objecten" "All supported properties" = "Alle ondersteunde eigenschappen" "All the fonts this file uses are installed" = "Alle lettertypen die dit bestand gebruikt zijn geïnstalleerd" "All variables" = "Alle variabelen" @@ -241,8 +243,10 @@ status = "draft — needs native review" "Circle" = "Cirkel" "Clear from this list" = "Verwijderen uit deze lijst" "Clear recent" = "Recenten wissen" +"Clear results" = "Resultaten wissen" "Click + to replace mixed content" = "Klik op + om gemengde inhoud te vervangen" "Click anywhere on the canvas to leave one." = "Klik ergens op het canvas om er een achter te laten." +"Click objects to vote; click your badge to take a vote back" = "Klik op objecten om te stemmen; klik op je badge om een stem terug te nemen" "Click or drag to merge · {alt} removes" = "Klik of sleep om samen te voegen · {alt} verwijdert" "Click or drag to remove · {alt} merges" = "Klik of sleep om te verwijderen · {alt} voegt samen" "Click to record a key" = "Klik om een toets op te nemen" @@ -463,6 +467,7 @@ status = "draft — needs native review" "Empty" = "Leeg" "End" = "End" "End point" = "Eindpunt" +"End vote" = "Stemronde beëindigen" "Ends the session; everyone keeps their copy" = "Beëindigt de sessie; iedereen houdt zijn eigen kopie" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Voer het adres van een figcraft-server in en druk op Bladeren om te zien welke plugins het aanbiedt." @@ -792,6 +797,7 @@ status = "draft — needs native review" "Min H" = "Min H" "Min W" = "Min. B" "Minimize" = "Minimaliseren" +"Minutes" = "Minuten" "Mirror angle" = "Hoek spiegelen" "Mirror angle and length" = "Spiegel hoek en lengte" "Mirroring" = "Spiegeling" @@ -889,6 +895,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Nog geen tekststijlen. Klik op + om er een te maken vanuit de geselecteerde tekst." "No variables fit here." = "Er passen hier geen variabelen." "No variables yet. Create them in Local variables." = "Nog geen variabelen. Maak ze in Lokale variabelen." +"No votes yet" = "Nog geen stemmen" "Nobody else is here yet" = "Er is nog niemand anders hier" "Noise" = "Ruis" "None" = "Geen" @@ -996,6 +1003,7 @@ status = "draft — needs native review" "Pattern alignment" = "Patroonuitlijning" "Pattern scale" = "Patroonschaal" "Pattern source" = "Patroonbron" +"Pause" = "Pauzeren" "Pen" = "Pen" "Pencil" = "Potlood" "People in this file" = "Personen in dit bestand" @@ -1158,6 +1166,8 @@ status = "draft — needs native review" "Restore main component" = "Hoofdcomponent terugzetten" "Restore this version" = "Deze versie terugzetten" "Restored “{name}”" = "“{name}” teruggezet" +"Results" = "Resultaten" +"Resume" = "Hervatten" "Reverse the order" = "Volgorde omdraaien" "Reversed triangle" = "Omgekeerde driehoek" "Review and merge" = "Controleren en samenvoegen" @@ -1337,17 +1347,20 @@ status = "draft — needs native review" "Start point" = "Startpunt" "Start sharing" = "Delen starten" "Start sharing with the Share button, then pass on the code it gives" = "Begin met delen via de knop Delen en geef daarna de code door die je krijgt" +"Start vote" = "Stemronde starten" "Starting to share…" = "Bezig met delen…" "Status" = "Status" "Sticky" = "Sticky" "Sticky note" = "Plakbriefje" "Sticky note colors" = "Kleuren van plakbriefjes" +"Sticky notes" = "Plakbriefjes" "Sticky notes, shapes and connectors" = "Plakbriefjes, vormen en verbindingen" "Stop" = "Stoppen" "Stop following {name}" = "Stop met {name} te volgen" "Stop session" = "Sessie stoppen" "Stop sharing" = "Stoppen met delen" "Stop spotlighting" = "Stop met uitlichten" +"Stop timer" = "Timer stoppen" "Stop {n} {hex}" = "Stop {n} {hex}" "Straight" = "Recht" "Stretch" = "Uitrekken" @@ -1433,6 +1446,7 @@ status = "draft — needs native review" "Tidy up" = "Opruimen" "Tile" = "Tegelen" "Tile type" = "Tegeltype" +"Timer" = "Timer" "Tint" = "Tint" "Title" = "Titel" "Title case" = "Hoofdletter per woord" @@ -1528,6 +1542,8 @@ status = "draft — needs native review" "Visibility" = "Zichtbaarheid" "Visible" = "Zichtbaar" "Vivid" = "Vivid" +"Votes per person" = "Stemmen per persoon" +"Voting session" = "Stemronde" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is ingesteld en geeft de te gebruiken grafische API aan; als je het uitzet, kiest FigCraft zelf." "Warm" = "Warm" "Watch" = "Watch" @@ -1746,6 +1762,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} laag geselecteerd", other = "{n} lagen geselecteerd" } "{n} layers · {parts}" = { one = "{n} laag · {parts}", other = "{n} lagen · {parts}" } "{n} matches" = { one = "{n} overeenkomst", other = "{n} overeenkomsten" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "{n} minuut geleden", other = "{n} minuten geleden" } "{n} months ago" = { one = "{n} maand geleden", other = "{n} maanden geleden" } "{n} more notes omitted from this report" = { one = "{n} extra notitie weggelaten uit dit rapport", other = "{n} extra notities weggelaten uit dit rapport" } @@ -1759,6 +1776,8 @@ status = "draft — needs native review" "{n} styles" = { one = "{n} stijl", other = "{n} stijlen" } "{n} text layers" = { one = "{n} tekstlaag", other = "{n} tekstlagen" } "{n} variables" = { one = "{n} variabele", other = "{n} variabelen" } +"{n} votes" = { one = "{n} stem", other = "{n} stemmen" } +"{n} votes left" = { one = "Nog {n} stem", other = "Nog {n} stemmen" } "{n} weeks ago" = { one = "{n} week geleden", other = "{n} weken geleden" } "{n} years ago" = { one = "{n} jaar geleden", other = "{n} jaar geleden" } "{n}d" = "{n}d" @@ -1821,6 +1840,10 @@ status = "draft — needs native review" "board.shape" = "Vorm met tekst" "board.stamp" = "Stempel" "board.sticky" = "Plakbriefje" +"board.timer" = "Timer" +"board.timerState" = "Timerstatus" +"board.vote" = "Stemronde" +"board.voteResults" = "Stemresultaten" "bool.exclude" = "Exclude toepassen op selectie" "bool.flatten" = "Boolean afvlakken" "bool.intersect" = "Intersect toepassen op selectie" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 30a9180a..7d28ba49 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -36,6 +36,7 @@ name = "Polski" "Add a description" = "Dodaj opis" "Add a frame (F) or a shape (R) to get started" = "Dodaj ramkę (F) lub kształt (R), aby rozpocząć" "Add a layer or component to tile first" = "Najpierw dodaj warstwę lub komponent do powielenia" +"Add a minute" = "Dodaj minutę" "Add a note for developers" = "Dodaj notatkę dla programistów" "Add action" = "Dodaj akcję" "Add actions for when no condition holds" = "Dodaj akcje wykonywane, gdy żaden warunek nie jest spełniony" @@ -91,6 +92,7 @@ name = "Polski" "All comments" = "Wszystkie komentarze" "All components" = "Wszystkie komponenty" "All fonts" = "Wszystkie czcionki" +"All objects" = "Wszystkie obiekty" "All supported properties" = "Wszystkie obsługiwane właściwości" "All the fonts this file uses are installed" = "Wszystkie czcionki używane w tym pliku są zainstalowane" "All variables" = "Wszystkie zmienne" @@ -211,6 +213,8 @@ name = "Polski" "Checking the server…" = "Sprawdzanie serwera…" "Chevron" = "Szewron" "Clear from this list" = "Usuń z tej listy" +"Clear results" = "Wyczyść wyniki" +"Click objects to vote; click your badge to take a vote back" = "Kliknij obiekty, aby głosować; kliknij swoją odznakę, aby cofnąć głos" "Code or invite link" = "Kod lub link z zaproszeniem" "Color profile: {name}" = "Profil kolorów: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Style kolorów i tekstu, zmienne Jasne i Ciemne, przyciski, pola wprowadzania i karty" @@ -462,6 +466,7 @@ name = "Polski" "Empty" = "Puste" "End" = "End" "End point" = "Punkt końcowy" +"End vote" = "Zakończ głosowanie" "Ends the session; everyone keeps their copy" = "Kończy sesję; każdy zachowuje swoją kopię" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Wprowadź adres figcraft-server i kliknij Przeglądaj, aby zobaczyć dostępne wtyczki." @@ -794,6 +799,7 @@ name = "Polski" "Min H" = "Min. wys." "Min W" = "Min. szer." "Minimize" = "Minimalizuj" +"Minutes" = "Minuty" "Mirror angle" = "Kąt odbicia lustrzanego" "Mirror angle and length" = "Kąt i długość odbicia lustrzanego" "Mirroring" = "Odbicie lustrzane" @@ -891,6 +897,7 @@ name = "Polski" "No text styles yet. Click + to create one from the selected text." = "Brak stylów tekstu. Kliknij +, aby utworzyć styl z zaznaczonego tekstu." "No variables fit here." = "Żadne zmienne tutaj nie pasują." "No variables yet. Create them in Local variables." = "Brak zmiennych. Utwórz je w Zmiennych lokalnych." +"No votes yet" = "Brak głosów" "Nobody else is here yet" = "Nikogo innego tu jeszcze nie ma" "Noise" = "Szum" "None" = "Brak" @@ -979,6 +986,7 @@ name = "Polski" "Page deleted" = "Strona została usunięta" "Pages" = "Strony" "Pages added to the document" = "Strony dodane do dokumentu" +"Pause" = "Wstrzymaj" "Pink" = "Różowy" "Placed on the current page" = "Umieszczono na bieżącej stronie" "Pages: {pages}" = "Strony: {pages}" @@ -1162,6 +1170,8 @@ name = "Polski" "Restore main component" = "Przywróć główny komponent" "Restore this version" = "Przywróć tę wersję" "Restored “{name}”" = "Przywrócono „{name}”" +"Results" = "Wyniki" +"Resume" = "Wznów" "Reverse the order" = "Odwróć kolejność" "Reversed triangle" = "Odwrócony trójkąt" "Review and merge" = "Przejrzyj i scal" @@ -1343,17 +1353,20 @@ name = "Polski" "Start point" = "Punkt początkowy" "Start sharing" = "Rozpocznij udostępnianie" "Start sharing with the Share button, then pass on the code it gives" = "Rozpocznij udostępnianie przyciskiem Udostępnij, a następnie przekaż wygenerowany kod" +"Start vote" = "Rozpocznij głosowanie" "Starting to share…" = "Rozpoczynanie udostępniania…" "Status" = "Stan" "Sticky" = "Przyklejony (sticky)" "Sticky note" = "Karteczka" "Sticky note colors" = "Kolory karteczek" +"Sticky notes" = "Karteczki" "Sticky notes, shapes and connectors" = "Karteczki, kształty i łączniki" "Stop" = "Zatrzymaj" "Stop following {name}" = "Przestań obserwować: {name}" "Stop session" = "Zatrzymaj sesję" "Stop sharing" = "Zatrzymaj udostępnianie" "Stop spotlighting" = "Zatrzymaj wyróżnianie widoku" +"Stop timer" = "Zatrzymaj minutnik" "Stop {n} {hex}" = "Punkt przejścia {n} {hex}" "Straight" = "Prosty" "Stretch" = "Rozciągnięcie" @@ -1441,6 +1454,7 @@ name = "Polski" "Tidy up" = "Uporządkuj" "Tile" = "Sąsiadująco" "Tile type" = "Typ kafelka" +"Timer" = "Minutnik" "Tint" = "Odcień barwny" "Title" = "Tytuł" "Title case" = "Jak W Tytule" @@ -1538,6 +1552,8 @@ name = "Polski" "Visibility" = "Widoczność" "Visible" = "Widoczne" "Vivid" = "Żywe kolory" +"Votes per person" = "Głosy na osobę" +"Voting session" = "Głosowanie" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Zmienna WGPU_BACKEND jest ustawiona i określa używane API graficzne; jej usunięcie pozwala FigCraft dokonać wyboru." "Warm" = "Ciepły" "Watch" = "Zegarek" @@ -1757,6 +1773,7 @@ name = "Polski" "{n} layers selected" = { one = "Zaznaczono {n} warstwę", few = "Zaznaczono {n} warstwy", many = "Zaznaczono {n} warstw", other = "Zaznaczono {n} warstw" } "{n} layers · {parts}" = { one = "{n} warstwa · {parts}", few = "{n} warstwy · {parts}", many = "{n} warstw · {parts}", other = "{n} warstw · {parts}" } "{n} matches" = { one = "{n} dopasowanie", few = "{n} dopasowania", many = "{n} dopasowań", other = "{n} dopasowań" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "{n} minutę temu", few = "{n} minuty temu", many = "{n} minut temu", other = "{n} minut temu" } "{n} months ago" = { one = "{n} miesiąc temu", few = "{n} miesiące temu", many = "{n} miesięcy temu", other = "{n} miesięcy temu" } "{n} more notes omitted from this report" = { one = "Pominięto jeszcze {n} notatkę w tym raporcie", few = "Pominięto jeszcze {n} notatki w tym raporcie", many = "Pominięto jeszcze {n} notatek w tym raporcie", other = "Pominięto jeszcze {n} notatek w tym raporcie" } @@ -1770,6 +1787,8 @@ name = "Polski" "{n} styles" = { one = "{n} styl", few = "{n} style", many = "{n} stylów", other = "{n} stylów" } "{n} text layers" = { one = "{n} warstwa tekstowa", few = "{n} warstwy tekstowe", many = "{n} warstw tekstowych", other = "{n} warstw tekstowych" } "{n} variables" = { one = "{n} zmienna", few = "{n} zmienne", many = "{n} zmiennych", other = "{n} zmiennych" } +"{n} votes" = { one = "{n} głos", few = "{n} głosy", many = "{n} głosów", other = "{n} głosu" } +"{n} votes left" = { one = "Pozostał {n} głos", few = "Pozostały {n} głosy", many = "Pozostało {n} głosów", other = "Pozostało {n} głosu" } "{n} weeks ago" = { one = "{n} tydzień temu", few = "{n} tygodnie temu", many = "{n} tygodni temu", other = "{n} tygodni temu" } "{n} years ago" = { one = "{n} rok temu", few = "{n} lata temu", many = "{n} lat temu", other = "{n} lat temu" } "{n}d" = "{n} d" @@ -1834,6 +1853,10 @@ name = "Polski" "board.shape" = "Kształt z tekstem" "board.stamp" = "Pieczątka" "board.sticky" = "Karteczka" +"board.timer" = "Minutnik" +"board.timerState" = "Stan minutnika" +"board.vote" = "Głosowanie" +"board.voteResults" = "Wyniki głosowania" "bool.exclude" = "Wyklucz zaznaczenie" "bool.flatten" = "Spłaszcz operację logiczną" "bool.intersect" = "Część wspólna zaznaczenia" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 713e6a3c..76a83d27 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -33,6 +33,7 @@ status = "draft — needs native review" "Add a description" = "Adicionar uma descrição" "Add a frame (F) or a shape (R) to get started" = "Adicione um quadro (F) ou uma forma (R) para começar" "Add a layer or component to tile first" = "Adicione primeiro uma camada ou um componente para o mosaico" +"Add a minute" = "Adicionar um minuto" "Add a note for developers" = "Adicione uma nota para desenvolvedores" "Add action" = "Adicionar ação" "Add actions for when no condition holds" = "Adicionar ações para quando nenhuma condição for atendida" @@ -87,6 +88,7 @@ status = "draft — needs native review" "All comments" = "Todos os comentários" "All components" = "Todos os componentes" "All fonts" = "Todas as fontes" +"All objects" = "Todos os objetos" "All supported properties" = "Todas as propriedades compatíveis" "All variables" = "Todas as variáveis" "Along the bottom" = "Ao longo da borda inferior" @@ -198,6 +200,8 @@ status = "draft — needs native review" "Checking the server…" = "Verificando o servidor…" "Chevron" = "Divisa" "Clear from this list" = "Limpar desta lista" +"Clear results" = "Limpar resultados" +"Click objects to vote; click your badge to take a vote back" = "Clique nos objetos para votar; clique no seu selo para retirar um voto" "Code or invite link" = "Código ou link de convite" "Color profile: {name}" = "Perfil de cor: {name}" "CSS custom properties…" = "Propriedades personalizadas CSS…" @@ -438,6 +442,7 @@ status = "draft — needs native review" "Empty" = "Vazio" "End" = "Fim" "End point" = "Ponto final" +"End vote" = "Encerrar votação" "Ends the session; everyone keeps their copy" = "Encerra a sessão; cada pessoa fica com a própria cópia" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Digite o endereço de um figcraft-server e pressione Procurar para ver os plugins que ele oferece." @@ -747,6 +752,7 @@ status = "draft — needs native review" "Min H" = "Alt. mín." "Min W" = "Larg. mín." "Minimize" = "Minimizar" +"Minutes" = "Minutos" "Mirror angle" = "Espelhar ângulo" "Mirror angle and length" = "Espelhar ângulo e comprimento" "Mirroring" = "Espelhamento" @@ -838,6 +844,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "Ainda não há estilos de texto. Clique em + para criar um a partir do texto selecionado." "No variables fit here." = "Nenhuma variável se encaixa aqui." "No variables yet. Create them in Local variables." = "Ainda não há variáveis. Crie-as em Variáveis locais." +"No votes yet" = "Nenhum voto ainda" "Nobody else is here yet" = "Ainda não há mais ninguém aqui" "Noise" = "Ruído" "None" = "Nenhum" @@ -919,6 +926,7 @@ status = "draft — needs native review" "Page deleted" = "Página excluída" "Pages" = "Páginas" "Pages added to the document" = "Páginas adicionadas ao documento" +"Pause" = "Pausar" "Pink" = "Rosa" "Placed on the current page" = "Colocado na página atual" "Pages: {pages}" = "Páginas: {pages}" @@ -1095,6 +1103,8 @@ status = "draft — needs native review" "Restore main component" = "Restaurar componente principal" "Restore this version" = "Restaurar esta versão" "Restored “{name}”" = "“{name}” restaurada" +"Results" = "Resultados" +"Resume" = "Retomar" "Reverse the order" = "Inverter a ordem" "Reversed triangle" = "Triângulo invertido" "Review and merge" = "Revisar e mesclar" @@ -1257,17 +1267,20 @@ status = "draft — needs native review" "Start on the Home screen" = "Começar no Início" "Start point" = "Ponto inicial" "Start sharing" = "Começar a compartilhar" +"Start vote" = "Iniciar votação" "Starting to share…" = "Começando a compartilhar…" "Status" = "Status" "Sticky" = "Fixado" "Sticky note" = "Nota adesiva" "Sticky note colors" = "Cores das notas adesivas" +"Sticky notes" = "Notas adesivas" "Sticky notes, shapes and connectors" = "Notas adesivas, formas e conectores" "Stop" = "Parar" "Stop following {name}" = "Parar de seguir {name}" "Stop session" = "Encerrar sessão" "Stop sharing" = "Parar de compartilhar" "Stop spotlighting" = "Remover o foco" +"Stop timer" = "Parar timer" "Stop {n} {hex}" = "Parada {n} {hex}" "Straight" = "Reto" "Stretch" = "Esticar" @@ -1350,6 +1363,7 @@ status = "draft — needs native review" "Tidy up" = "Organizar" "Tile" = "Mosaico" "Tile type" = "Tipo de mosaico" +"Timer" = "Timer" "Tint" = "Tonalidade" "Title" = "Título" "Title case" = "Iniciais maiúsculas" @@ -1441,6 +1455,8 @@ status = "draft — needs native review" "Visibility" = "Visibilidade" "Visible" = "Visível" "Vivid" = "Vívido" +"Votes per person" = "Votos por pessoa" +"Voting session" = "Sessão de votação" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND está definida e indica a API gráfica a usar; sem ela, o FigCraft escolhe sozinho." "Warm" = "Quente" "Watch" = "Relógio" @@ -1652,6 +1668,7 @@ status = "draft — needs native review" "{n} layers selected" = { one = "{n} camada selecionada", other = "{n} camadas selecionadas" } "{n} layers · {parts}" = { one = "{n} camada · {parts}", other = "{n} camadas · {parts}" } "{n} matches" = { one = "{n} resultado", other = "{n} resultados" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "há {n} minuto", other = "há {n} minutos" } "{n} months ago" = { one = "há {n} mês", other = "há {n} meses" } "{n} more notes omitted from this report" = { one = "{n} nota a mais omitida deste relatório", other = "{n} notas a mais omitidas deste relatório" } @@ -1664,6 +1681,8 @@ status = "draft — needs native review" "{n} rows" = { one = "{n} linha", other = "{n} linhas" } "{n} styles" = { one = "{n} estilo", other = "{n} estilos" } "{n} variables" = { one = "{n} variável", other = "{n} variáveis" } +"{n} votes" = { one = "{n} voto", other = "{n} votos" } +"{n} votes left" = { one = "{n} voto restante", other = "{n} votos restantes" } "{n} weeks ago" = { one = "há {n} semana", other = "há {n} semanas" } "{n} years ago" = { one = "há {n} ano", other = "há {n} anos" } "{n}d" = "{n} d" @@ -1840,6 +1859,10 @@ status = "draft — needs native review" "board.shape" = "Forma com texto" "board.stamp" = "Carimbo" "board.sticky" = "Nota adesiva" +"board.timer" = "Timer" +"board.timerState" = "Estado do timer" +"board.vote" = "Sessão de votação" +"board.voteResults" = "Resultados da votação" "bool.exclude" = "Excluir sobreposição da seleção" "bool.flatten" = "Nivelar booleano" "bool.intersect" = "Interseção da seleção" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 1a6b59bc..f383cf08 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -36,6 +36,7 @@ name = "Русский" "Add a description" = "Добавить описание" "Add a frame (F) or a shape (R) to get started" = "Для начала добавьте Frame (F) или фигуру (R)" "Add a layer or component to tile first" = "Сначала добавьте слой или компонент для повторения" +"Add a minute" = "Добавить минуту" "Add a note for developers" = "Добавить заметку для разработчиков" "Add action" = "Добавить действие" "Add actions for when no condition holds" = "Добавить действия на случай, если ни одно условие не выполнено" @@ -91,6 +92,7 @@ name = "Русский" "All comments" = "Все комментарии" "All components" = "Все компоненты" "All fonts" = "Все шрифты" +"All objects" = "Все объекты" "All supported properties" = "Все поддерживаемые свойства" "All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены" "All variables" = "Все переменные" @@ -211,6 +213,8 @@ name = "Русский" "Checking the server…" = "Проверка сервера…" "Chevron" = "Шеврон" "Clear from this list" = "Убрать из этого списка" +"Clear results" = "Очистить результаты" +"Click objects to vote; click your badge to take a vote back" = "Нажмите на объекты, чтобы проголосовать; нажмите на свой значок, чтобы забрать голос" "Code or invite link" = "Код или ссылка приглашения" "Color profile: {name}" = "Цветовой профиль: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Цветовые и текстовые стили, переменные светлой и тёмной тем, кнопки, поля ввода и карточки" @@ -462,6 +466,7 @@ name = "Русский" "Empty" = "Пусто" "End" = "Конец" "End point" = "Конечная точка" +"End vote" = "Завершить голосование" "Ends the session; everyone keeps their copy" = "Завершает сессию; у всех остаются свои копии" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Введите адрес figcraft-server и нажмите «Обзор», чтобы увидеть доступные плагины." @@ -794,6 +799,7 @@ name = "Русский" "Min H" = "Мин. В" "Min W" = "Мин. Ш" "Minimize" = "Свернуть окно" +"Minutes" = "Минуты" "Mirror angle" = "Отражать угол" "Mirror angle and length" = "Отражать угол и длину" "Mirroring" = "Зеркальность" @@ -891,6 +897,7 @@ name = "Русский" "No text styles yet. Click + to create one from the selected text." = "Текстовых стилей пока нет. Нажмите +, чтобы создать стиль из выделенного текста." "No variables fit here." = "Нет подходящих переменных." "No variables yet. Create them in Local variables." = "Переменных пока нет. Создайте их в разделе «Локальные переменные»." +"No votes yet" = "Голосов пока нет" "Nobody else is here yet" = "Здесь пока больше никого нет" "Noise" = "Шум" "None" = "Нет" @@ -979,6 +986,7 @@ name = "Русский" "Page deleted" = "Страница удалена" "Pages" = "Страницы" "Pages added to the document" = "Страницы добавлены в документ" +"Pause" = "Пауза" "Pink" = "Розовый" "Placed on the current page" = "Размещено на текущей странице" "Pages: {pages}" = "Страницы: {pages}" @@ -1162,6 +1170,8 @@ name = "Русский" "Restore main component" = "Восстановить основной компонент" "Restore this version" = "Восстановить эту версию" "Restored “{name}”" = "«{name}» восстановлено" +"Results" = "Результаты" +"Resume" = "Продолжить" "Reverse the order" = "Обратить порядок" "Reversed triangle" = "Обратный треугольник" "Review and merge" = "Проверить и объединить" @@ -1343,17 +1353,20 @@ name = "Русский" "Start point" = "Начальная точка" "Start sharing" = "Включить общий доступ" "Start sharing with the Share button, then pass on the code it gives" = "Включите общий доступ кнопкой «Поделиться», затем передайте полученный код" +"Start vote" = "Начать голосование" "Starting to share…" = "Включение общего доступа…" "Status" = "Статус" "Sticky" = "Закреплённый" "Sticky note" = "Стикер" "Sticky note colors" = "Цвета стикеров" +"Sticky notes" = "Стикеры" "Sticky notes, shapes and connectors" = "Стикеры, фигуры и соединители" "Stop" = "Остановить" "Stop following {name}" = "Перестать следить за {name}" "Stop session" = "Завершить сессию" "Stop sharing" = "Остановить общий доступ" "Stop spotlighting" = "Прекратить показ всем" +"Stop timer" = "Остановить таймер" "Stop {n} {hex}" = "Точка {n} {hex}" "Straight" = "Прямая" "Stretch" = "Растянуть" @@ -1441,6 +1454,7 @@ name = "Русский" "Tidy up" = "Упорядочить" "Tile" = "Повторение" "Tile type" = "Тип повторения" +"Timer" = "Таймер" "Tint" = "Оттенок" "Title" = "Заголовок" "Title case" = "Каждое Слово С Прописной" @@ -1538,6 +1552,8 @@ name = "Русский" "Visibility" = "Видимость" "Visible" = "Видимый" "Vivid" = "Яркий" +"Votes per person" = "Голосов на человека" +"Voting session" = "Голосование" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Переменная WGPU_BACKEND задана и указывает используемый графический API; если её убрать, FigCraft выберет сам." "Warm" = "Тёплый" "Watch" = "Часы" @@ -1757,6 +1773,7 @@ name = "Русский" "{n} layers selected" = { one = "Выделен {n} слой", few = "Выделены {n} слоя", many = "Выделено {n} слоёв", other = "Выделено {n} слоя" } "{n} layers · {parts}" = { one = "{n} слой · {parts}", few = "{n} слоя · {parts}", many = "{n} слоёв · {parts}", other = "{n} слоя · {parts}" } "{n} matches" = { one = "{n} совпадение", few = "{n} совпадения", many = "{n} совпадений", other = "{n} совпадения" } +"{n} min" = "{n} мин" "{n} minutes ago" = { one = "{n} минуту назад", few = "{n} минуты назад", many = "{n} минут назад", other = "{n} минуты назад" } "{n} months ago" = { one = "{n} месяц назад", few = "{n} месяца назад", many = "{n} месяцев назад", other = "{n} месяца назад" } "{n} more notes omitted from this report" = { one = "Ещё {n} замечание не включено в этот отчёт", few = "Ещё {n} замечания не включены в этот отчёт", many = "Ещё {n} замечаний не включено в этот отчёт", other = "Ещё {n} замечания не включено в этот отчёт" } @@ -1770,6 +1787,8 @@ name = "Русский" "{n} styles" = { one = "{n} стиль", few = "{n} стиля", many = "{n} стилей", other = "{n} стиля" } "{n} text layers" = { one = "{n} текстовый слой", few = "{n} текстовых слоя", many = "{n} текстовых слоёв", other = "{n} текстовых слоя" } "{n} variables" = { one = "{n} переменная", few = "{n} переменные", many = "{n} переменных", other = "{n} переменной" } +"{n} votes" = { one = "{n} голос", few = "{n} голоса", many = "{n} голосов", other = "{n} голоса" } +"{n} votes left" = { one = "Остался {n} голос", few = "Осталось {n} голоса", many = "Осталось {n} голосов", other = "Осталось {n} голоса" } "{n} weeks ago" = { one = "{n} неделю назад", few = "{n} недели назад", many = "{n} недель назад", other = "{n} недели назад" } "{n} years ago" = { one = "{n} год назад", few = "{n} года назад", many = "{n} лет назад", other = "{n} года назад" } "{n}d" = "{n} д" @@ -1834,6 +1853,10 @@ name = "Русский" "board.shape" = "Фигура с текстом" "board.stamp" = "Штамп" "board.sticky" = "Стикер" +"board.timer" = "Таймер" +"board.timerState" = "Состояние таймера" +"board.vote" = "Голосование" +"board.voteResults" = "Результаты голосования" "bool.exclude" = "Исключить выделение" "bool.flatten" = "Свести булеву группу" "bool.intersect" = "Пересечь выделение" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 7624674d..46533735 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -36,6 +36,7 @@ name = "Svenska" "Add a description" = "Lägg till en beskrivning" "Add a frame (F) or a shape (R) to get started" = "Lägg till en frame (F) eller en form (R) för att komma igång" "Add a layer or component to tile first" = "Lägg först till ett lager eller en komponent att lägga sida vid sida" +"Add a minute" = "Lägg till en minut" "Add a note for developers" = "Lägg till en anteckning för utvecklare" "Add action" = "Lägg till åtgärd" "Add actions for when no condition holds" = "Lägg till åtgärder för när inget villkor uppfylls" @@ -91,6 +92,7 @@ name = "Svenska" "All comments" = "Alla kommentarer" "All components" = "Alla komponenter" "All fonts" = "Alla typsnitt" +"All objects" = "Alla objekt" "All supported properties" = "Alla egenskaper som stöds" "All the fonts this file uses are installed" = "Alla typsnitt som filen använder är installerade" "All variables" = "Alla variabler" @@ -211,6 +213,8 @@ name = "Svenska" "Checking the server…" = "Kontrollerar servern…" "Chevron" = "Vinkel" "Clear from this list" = "Ta bort från listan" +"Clear results" = "Rensa resultat" +"Click objects to vote; click your badge to take a vote back" = "Klicka på objekt för att rösta; klicka på din bricka för att ta tillbaka en röst" "Code or invite link" = "Kod eller inbjudningslänk" "Color profile: {name}" = "Färgprofil: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Färg- och textstilar, ljusa och mörka variabler, knappar, inmatningsfält och kort" @@ -462,6 +466,7 @@ name = "Svenska" "Empty" = "Tom" "End" = "Slut" "End point" = "Slutpunkt" +"End vote" = "Avsluta omröstning" "Ends the session; everyone keeps their copy" = "Avslutar sessionen; alla behåller sin kopia" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Ange adressen till en figcraft-server och tryck på Bläddra för att se vilka plugins den erbjuder." @@ -794,6 +799,7 @@ name = "Svenska" "Min H" = "Min H" "Min W" = "Min B" "Minimize" = "Minimera" +"Minutes" = "Minuter" "Mirror angle" = "Spegla vinkel" "Mirror angle and length" = "Spegla vinkel och längd" "Mirroring" = "Spegling" @@ -891,6 +897,7 @@ name = "Svenska" "No text styles yet. Click + to create one from the selected text." = "Inga textstilar än. Klicka på + för att skapa en från den markerade texten." "No variables fit here." = "Inga variabler passar här." "No variables yet. Create them in Local variables." = "Inga variabler än. Skapa dem i Lokala variabler." +"No votes yet" = "Inga röster än" "Nobody else is here yet" = "Ingen annan är här än" "Noise" = "Brus" "None" = "Ingen" @@ -979,6 +986,7 @@ name = "Svenska" "Page deleted" = "Sidan har raderats" "Pages" = "Sidor" "Pages added to the document" = "Sidor tillagda i dokumentet" +"Pause" = "Pausa" "Pink" = "Rosa" "Placed on the current page" = "Placerat på den aktuella sidan" "Pages: {pages}" = "Sidor: {pages}" @@ -1162,6 +1170,8 @@ name = "Svenska" "Restore main component" = "Återställ huvudkomponent" "Restore this version" = "Återställ den här versionen" "Restored “{name}”" = "Återställde ”{name}”" +"Results" = "Resultat" +"Resume" = "Återuppta" "Reverse the order" = "Vänd på ordningen" "Reversed triangle" = "Omvänd triangel" "Review and merge" = "Granska och sammanfoga" @@ -1343,17 +1353,20 @@ name = "Svenska" "Start point" = "Startpunkt" "Start sharing" = "Börja dela" "Start sharing with the Share button, then pass on the code it gives" = "Börja dela med knappen Dela och skicka sedan vidare koden du får" +"Start vote" = "Starta omröstning" "Starting to share…" = "Börjar dela…" "Status" = "Status" "Sticky" = "Fast" "Sticky note" = "Lapp" "Sticky note colors" = "Lappfärger" +"Sticky notes" = "Lappar" "Sticky notes, shapes and connectors" = "Lappar, former och kopplingar" "Stop" = "Stoppa" "Stop following {name}" = "Sluta följa {name}" "Stop session" = "Avsluta session" "Stop sharing" = "Sluta dela" "Stop spotlighting" = "Sluta använda strålkastaren" +"Stop timer" = "Stoppa timern" "Stop {n} {hex}" = "Stopp {n} {hex}" "Straight" = "Rak" "Stretch" = "Sträck" @@ -1441,6 +1454,7 @@ name = "Svenska" "Tidy up" = "Snygga till" "Tile" = "Sida vid sida" "Tile type" = "Upprepningstyp" +"Timer" = "Timer" "Tint" = "Färgton" "Title" = "Titel" "Title case" = "Inledande versaler" @@ -1538,6 +1552,8 @@ name = "Svenska" "Visibility" = "Synlighet" "Visible" = "Synlig" "Vivid" = "Livfull" +"Votes per person" = "Röster per person" +"Voting session" = "Omröstning" "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" @@ -1757,6 +1773,7 @@ name = "Svenska" "{n} layers selected" = { one = "{n} lager markerat", other = "{n} lager markerade" } "{n} layers · {parts}" = { one = "{n} lager · {parts}", other = "{n} lager · {parts}" } "{n} matches" = { one = "{n} träff", other = "{n} träffar" } +"{n} min" = "{n} min" "{n} minutes ago" = { one = "för {n} minut sedan", other = "för {n} minuter sedan" } "{n} months ago" = { one = "för {n} månad sedan", other = "för {n} månader sedan" } "{n} more notes omitted from this report" = { one = "{n} anteckning till utelämnades i den här rapporten", other = "{n} anteckningar till utelämnades i den här rapporten" } @@ -1770,6 +1787,8 @@ name = "Svenska" "{n} styles" = { one = "{n} formatmall", other = "{n} formatmallar" } "{n} text layers" = { one = "{n} textlager", other = "{n} textlager" } "{n} variables" = { one = "{n} variabel", other = "{n} variabler" } +"{n} votes" = { one = "{n} röst", other = "{n} röster" } +"{n} votes left" = { one = "{n} röst kvar", other = "{n} röster kvar" } "{n} weeks ago" = { one = "för {n} vecka sedan", other = "för {n} veckor sedan" } "{n} years ago" = { one = "för {n} år sedan", other = "för {n} år sedan" } "{n}d" = "{n} d" @@ -1835,6 +1854,10 @@ name = "Svenska" "board.shape" = "Form med text" "board.stamp" = "Stämpel" "board.sticky" = "Lapp" +"board.timer" = "Timer" +"board.timerState" = "Timerstatus" +"board.vote" = "Omröstning" +"board.voteResults" = "Omröstningsresultat" "bool.exclude" = "Uteslut markering" "bool.flatten" = "Platta till boolesk form" "bool.intersect" = "Skärning av markering" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index ed7640ad..6eeb7774 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -37,6 +37,7 @@ name = "Türkçe" "Add a description" = "Açıklama ekle" "Add a frame (F) or a shape (R) to get started" = "Başlamak için bir çerçeve (F) veya bir şekil (R) ekleyin" "Add a layer or component to tile first" = "Önce döşenecek bir katman veya bileşen ekleyin" +"Add a minute" = "Bir dakika ekle" "Add a note for developers" = "Geliştiriciler için not ekle" "Add action" = "Eylem ekle" "Add actions for when no condition holds" = "Hiçbir koşul sağlanmadığında yapılacak eylemleri ekleyin" @@ -91,6 +92,7 @@ name = "Türkçe" "All comments" = "Tüm yorumlar" "All components" = "Tüm bileşenler" "All fonts" = "Tüm yazı tipleri" +"All objects" = "Tüm nesneler" "All supported properties" = "Desteklenen tüm özellikler" "All variables" = "Tüm değişkenler" "Along the bottom" = "Alt kenar boyunca" @@ -208,6 +210,8 @@ name = "Türkçe" "Checking the server…" = "Sunucu denetleniyor…" "Chevron" = "Şerit ok" "Clear from this list" = "Bu listeden kaldır" +"Clear results" = "Sonuçları temizle" +"Click objects to vote; click your badge to take a vote back" = "Oy vermek için nesnelere tıklayın; oyu geri almak için rozetinize tıklayın" "Code or invite link" = "Kod veya davet bağlantısı" "Color profile: {name}" = "Renk profili: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Renk ve metin stilleri, Açık ve Koyu değişkenler, düğmeler, giriş alanları ve kartlar" @@ -452,6 +456,7 @@ name = "Türkçe" "Empty" = "Boş" "End" = "Bitiş" "End point" = "Bitiş noktası" +"End vote" = "Oylamayı bitir" "Ends the session; everyone keeps their copy" = "Oturumu sonlandırır; herkes kendi kopyasını saklar" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Bir figcraft-server adresi girin ve sunduğu eklentileri görmek için Gözat'a basın." @@ -779,6 +784,7 @@ name = "Türkçe" "Min H" = "En küçük Y" "Min W" = "En küçük G" "Minimize" = "Simge durumuna küçült" +"Minutes" = "Dakika" "Mirror angle" = "Açıyı yansıt" "Mirror angle and length" = "Açıyı ve uzunluğu yansıt" "Mirroring" = "Yansıtma" @@ -873,6 +879,7 @@ name = "Türkçe" "No text styles yet. Click + to create one from the selected text." = "Henüz metin stili yok. Seçili metinden oluşturmak için + simgesine tıklayın." "No variables fit here." = "Buraya uyan değişken yok." "No variables yet. Create them in Local variables." = "Henüz değişken yok. Onları Yerel değişkenler'de oluşturun." +"No votes yet" = "Henüz oy yok" "Nobody else is here yet" = "Burada henüz başka kimse yok" "Noise" = "Gürültü" "None" = "Yok" @@ -958,6 +965,7 @@ name = "Türkçe" "Page deleted" = "Sayfa silindi" "Pages" = "Sayfalar" "Pages added to the document" = "Sayfalar belgeye eklendi" +"Pause" = "Duraklat" "Pink" = "Pembe" "Placed on the current page" = "Geçerli sayfaya yerleştirildi" "Pages: {pages}" = "Sayfalar: {pages}" @@ -1136,6 +1144,8 @@ name = "Türkçe" "Restore main component" = "Ana bileşeni geri yükle" "Restore this version" = "Bu sürümü geri yükle" "Restored “{name}”" = "“{name}” geri yüklendi" +"Results" = "Sonuçlar" +"Resume" = "Sürdür" "Reverse the order" = "Sırayı ters çevir" "Reversed triangle" = "Ters üçgen" "Review and merge" = "Gözden geçir ve birleştir" @@ -1311,17 +1321,20 @@ name = "Türkçe" "Start point" = "Başlangıç noktası" "Start sharing" = "Paylaşmaya başla" "Start sharing with the Share button, then pass on the code it gives" = "Paylaş düğmesiyle paylaşmaya başlayın, sonra verdiği kodu iletin" +"Start vote" = "Oylamayı başlat" "Starting to share…" = "Paylaşım başlatılıyor…" "Status" = "Durum" "Sticky" = "Yapışkan" "Sticky note" = "Yapışkan not" "Sticky note colors" = "Yapışkan not renkleri" +"Sticky notes" = "Yapışkan notlar" "Sticky notes, shapes and connectors" = "Yapışkan notlar, şekiller ve bağlayıcılar" "Stop" = "Durdur" "Stop following {name}" = "{name} takibini bırak" "Stop session" = "Oturumu durdur" "Stop sharing" = "Paylaşmayı durdur" "Stop spotlighting" = "Öne çıkarmayı durdur" +"Stop timer" = "Zamanlayıcıyı durdur" "Stop {n} {hex}" = "Durak {n} {hex}" "Straight" = "Düz" "Stretch" = "Uzat" @@ -1403,6 +1416,7 @@ name = "Türkçe" "Tidy up" = "Toparla" "Tile" = "Döşe" "Tile type" = "Döşeme türü" +"Timer" = "Zamanlayıcı" "Tint" = "Renk tonu" "Title" = "Başlık" "Title case" = "Başlık biçimi" @@ -1495,6 +1509,8 @@ name = "Türkçe" "Visibility" = "Görünürlük" "Visible" = "Görünür" "Vivid" = "Canlı" +"Votes per person" = "Kişi başı oy" +"Voting session" = "Oylama oturumu" "Warm" = "Sıcak" "Watch" = "İzle" "Wavy" = "Dalgalı" @@ -1710,6 +1726,7 @@ name = "Türkçe" "{n} layers selected" = { other = "{n} katman seçili" } "{n} layers · {parts}" = { other = "{n} katman · {parts}" } "{n} matches" = { other = "{n} eşleşme" } +"{n} min" = "{n} dk" "{n} minutes ago" = { other = "{n} dakika önce" } "{n} months ago" = { other = "{n} ay önce" } "{n} more notes omitted from this report" = { other = "{n} not daha bu rapora alınmadı" } @@ -1722,6 +1739,8 @@ name = "Türkçe" "{n} rows" = { other = "{n} satır" } "{n} styles" = { other = "{n} stil" } "{n} variables" = { other = "{n} değişken" } +"{n} votes" = { other = "{n} oy" } +"{n} votes left" = { other = "{n} oy kaldı" } "{n} weeks ago" = { other = "{n} hafta önce" } "{n} years ago" = { other = "{n} yıl önce" } "{n}d" = "{n} g" @@ -1784,6 +1803,10 @@ name = "Türkçe" "board.shape" = "Metinli şekil" "board.stamp" = "Damga" "board.sticky" = "Yapışkan not" +"board.timer" = "Zamanlayıcı" +"board.timerState" = "Zamanlayıcı durumu" +"board.vote" = "Oylama oturumu" +"board.voteResults" = "Oylama sonuçları" "bool.exclude" = "Seçimi hariç tut" "bool.flatten" = "Boolean'ı düzleştir" "bool.intersect" = "Seçimi kesiştir" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 82efa9c9..8c85f860 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -36,6 +36,7 @@ status = "draft — needs native review" "Add a description" = "添加描述" "Add a frame (F) or a shape (R) to get started" = "添加画框(F)或形状(R)以开始" "Add a layer or component to tile first" = "请先添加要平铺的图层或组件" +"Add a minute" = "加一分钟" "Add a note for developers" = "为开发者添加备注" "Add action" = "添加操作" "Add actions for when no condition holds" = "添加所有条件都不满足时的操作" @@ -90,6 +91,7 @@ status = "draft — needs native review" "All comments" = "全部评论" "All components" = "全部组件" "All fonts" = "全部字体" +"All objects" = "所有对象" "All supported properties" = "所有支持的属性" "All variables" = "全部变量" "Along the bottom" = "沿底边" @@ -201,6 +203,8 @@ status = "draft — needs native review" "Checking the server…" = "正在检查服务器…" "Chevron" = "箭头形" "Clear from this list" = "从此列表中清除" +"Clear results" = "清除结果" +"Click objects to vote; click your badge to take a vote back" = "点击对象投票;点击你的徽章收回一票" "Code or invite link" = "代码或邀请链接" "Color profile: {name}" = "颜色配置文件:{name}" "CSS custom properties…" = "CSS 自定义属性…" @@ -443,6 +447,7 @@ status = "draft — needs native review" "Empty" = "空" "End" = "末尾" "End point" = "终点" +"End vote" = "结束投票" "Ends the session; everyone keeps their copy" = "结束会话;每个人保留自己的副本" "Enter" = "Enter" "Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "输入 figcraft-server 的地址,然后按“浏览”查看它提供的插件。" @@ -753,6 +758,7 @@ status = "draft — needs native review" "Min H" = "最小高" "Min W" = "最小宽" "Minimize" = "最小化" +"Minutes" = "分钟" "Mirror angle" = "镜像角度" "Mirror angle and length" = "镜像角度和长度" "Mirroring" = "镜像" @@ -844,6 +850,7 @@ status = "draft — needs native review" "No text styles yet. Click + to create one from the selected text." = "还没有文本样式。点击 + 从所选文本创建。" "No variables fit here." = "没有适用于此处的变量。" "No variables yet. Create them in Local variables." = "还没有变量。请在“本地变量”中创建。" +"No votes yet" = "暂无投票" "Nobody else is here yet" = "这里还没有其他人" "Noise" = "杂色" "None" = "无" @@ -925,6 +932,7 @@ status = "draft — needs native review" "Page deleted" = "页面已删除" "Pages" = "页面" "Pages added to the document" = "页面已添加到文档" +"Pause" = "暂停" "Pink" = "粉色" "Placed on the current page" = "已放置在当前页面" "Pages: {pages}" = "页面:{pages}" @@ -1104,6 +1112,8 @@ status = "draft — needs native review" "Restore main component" = "恢复主组件" "Restore this version" = "恢复此版本" "Restored “{name}”" = "已恢复“{name}”" +"Results" = "结果" +"Resume" = "继续" "Reverse the order" = "反转顺序" "Reversed triangle" = "反向三角形" "Review and merge" = "审阅并合并" @@ -1266,17 +1276,20 @@ status = "draft — needs native review" "Start on the Home screen" = "启动时显示主页" "Start point" = "起点" "Start sharing" = "开始分享" +"Start vote" = "开始投票" "Starting to share…" = "正在开始分享…" "Status" = "状态" "Sticky" = "粘性" "Sticky note" = "便利贴" "Sticky note colors" = "便利贴颜色" +"Sticky notes" = "便利贴" "Sticky notes, shapes and connectors" = "便利贴、形状和连接线" "Stop" = "停止" "Stop following {name}" = "停止跟随 {name}" "Stop session" = "停止会话" "Stop sharing" = "停止分享" "Stop spotlighting" = "停止聚焦" +"Stop timer" = "停止计时器" "Stop {n} {hex}" = "色标 {n} {hex}" "Straight" = "直线" "Stretch" = "拉伸" @@ -1359,6 +1372,7 @@ status = "draft — needs native review" "Tidy up" = "整理" "Tile" = "平铺" "Tile type" = "平铺类型" +"Timer" = "计时器" "Tint" = "色调" "Title" = "标题" "Title case" = "首字母大写" @@ -1450,6 +1464,8 @@ status = "draft — needs native review" "Visibility" = "可见性" "Visible" = "可见" "Vivid" = "鲜艳" +"Votes per person" = "每人票数" +"Voting session" = "投票" "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "已设置 WGPU_BACKEND,它指定了要使用的图形 API;取消设置后由 FigCraft 自行选择。" "Warm" = "暖调" "Watch" = "手表" @@ -1661,6 +1677,7 @@ status = "draft — needs native review" "{n} layers selected" = { other = "已选择 {n} 个图层" } "{n} layers · {parts}" = { other = "{n} 个图层 · {parts}" } "{n} matches" = { other = "{n} 个匹配项" } +"{n} min" = "{n} 分钟" "{n} minutes ago" = { other = "{n} 分钟前" } "{n} months ago" = { other = "{n} 个月前" } "{n} more notes omitted from this report" = { other = "另有 {n} 条说明未列入此报告" } @@ -1673,6 +1690,8 @@ status = "draft — needs native review" "{n} rows" = { other = "{n} 行" } "{n} styles" = { other = "{n} 个样式" } "{n} variables" = { other = "{n} 个变量" } +"{n} votes" = { other = "{n} 票" } +"{n} votes left" = { other = "剩余 {n} 票" } "{n} weeks ago" = { other = "{n} 周前" } "{n} years ago" = { other = "{n} 年前" } "{n}d" = "{n}天" @@ -1735,6 +1754,10 @@ status = "draft — needs native review" "board.shape" = "带文字的形状" "board.stamp" = "印章" "board.sticky" = "便利贴" +"board.timer" = "计时器" +"board.timerState" = "计时器状态" +"board.vote" = "投票" +"board.voteResults" = "投票结果" "bool.exclude" = "排除所选项" "bool.flatten" = "拼合布尔组" "bool.intersect" = "所选项相交" diff --git a/crates/ui-egui/src/board_facilitate.rs b/crates/ui-egui/src/board_facilitate.rs new file mode 100644 index 00000000..2c5c66ab --- /dev/null +++ b/crates/ui-egui/src/board_facilitate.rs @@ -0,0 +1,257 @@ +//! Boards in the UI: the timer and voting pill at the top right of a board, and the vote badges on +//! objects. Everything they change goes through `board.timer` and `board.vote`. + +use std::time::Duration; + +use egui::{Align2, Color32, CornerRadius, Rect, RichText, Stroke, Ui, pos2, vec2}; +use figcraft_doc::{BoardTimer, NodeId}; +use figcraft_engine::cmd::facilitate::{self, BADGE_PX}; +use serde_json::{Value, json}; + +use crate::FigApp; +use crate::i18n::{tr, tr_noop, trf, trn}; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, regular, semibold}; +use crate::widgets; + +const CELL: f32 = 28.0; +/// The presets the timer offers (minutes). +const PRESETS: [u32; 5] = [1, 3, 5, 10, 15]; +/// What a vote can be on, as `board.vote {on}` takes it. +const SCOPES: [(&str, &str); 3] = [("all", tr_noop("All objects")), ("stickies", tr_noop("Sticky notes")), ("selection", tr_noop("Selection"))]; + +/// What the pill shows of the vote: none, running (your votes left) or ended (the results). +enum VoteView { + Off, + Running { left: u32 }, + Ended { results: Vec<(usize, NodeId, String, u32)> }, +} + +fn vote_view(app: &FigApp) -> VoteView { + let Ok(doc) = app.session.doc() else { return VoteView::Off }; + let Some(vote) = &doc.vote else { return VoteView::Off }; + if vote.ended { + let results = facilitate::ranked(doc, vote).into_iter().map(|(rank, id, n)| (rank, id, facilitate::object_name(doc, id), n)).collect(); + return VoteView::Ended { results }; + } + let used: u32 = facilitate::mine(doc, &facilitate::voter(&app.session, &Value::Null)).iter().map(|m| m.1).sum(); + VoteView::Running { left: vote.per_person.saturating_sub(used) } +} + +/// The pill at the top right of a board's canvas: the timer, then the vote. +pub fn show(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { + let Ok(doc) = app.session.doc() else { return }; + if !figcraft_engine::board::is_board(doc) { + return; + } + let timer = doc.timer; + let now = facilitate::now_ms(); + if let Some(left) = timer.filter(|t| matches!(t, BoardTimer::Running { .. })).map(|t| t.left(now)).filter(|l| *l > 0) { + // Wake when the shown second changes; nothing else ticks. + ctx.request_repaint_after(Duration::from_millis(left % 1000 + 5)); + } + let vote = vote_view(app); + let editable = app.session.active_read_only().is_none(); + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::unique("board_facilitate")) + .order(egui::Order::Middle) + .pivot(Align2::RIGHT_TOP) + .fixed_pos(pos2(canvas.max.x - 12.0, canvas.min.y + 12.0)) + .show(ctx, |ui| { + egui::Frame::NONE + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(10)) + .shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow }) + .inner_margin(egui::Margin::same(4)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 2.0; + timer_part(app, ui, timer, now, editable, &t); + ui.add_space(4.0); + ui.separator(); + ui.add_space(4.0); + vote_part(app, ui, vote, editable, &t); + }); + }); + }); +} + +fn timer_part(app: &mut FigApp, ui: &mut Ui, timer: Option, now: u64, editable: bool, t: &Tokens) { + let Some(timer) = timer else { + let resp = icons::button(ui, Icon::Timer, CELL, false, tr("Timer")); + if editable { + timer_popup(app, &resp); + } + return; + }; + let state = facilitate::timer_state(Some(timer), now); + let color = match state { + "done" => t.danger, + "paused" => t.text_secondary, + _ => t.text, + }; + let (r, _) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover()); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::Timer, color); + // As wide as the same clock in zeros: the pill doesn't jitter as the seconds change. + let text = facilitate::clock(timer.left(now)); + let zeros: String = text.chars().map(|c| if c.is_ascii_digit() { '0' } else { c }).collect(); + let w = ui.painter().layout_no_wrap(zeros, semibold(13.0), color).size().x; + let (r, _) = ui.allocate_exact_size(vec2(w + 2.0, CELL), egui::Sense::hover()); + ui.painter().text(r.left_center(), Align2::LEFT_CENTER, text, semibold(13.0), color); + ui.add_space(2.0); + if !editable { + return; + } + let (icon, tip, action) = match timer { + BoardTimer::Paused { .. } => (Icon::Play, tr("Resume"), "resume"), + BoardTimer::Running { .. } => (Icon::Pause, tr("Pause"), "pause"), + }; + if state != "done" && icons::button(ui, icon, CELL, false, tip).clicked() { + app.run("board.timer", json!({"action": action})); + } + if icons::button(ui, Icon::Plus, CELL, false, tr("Add a minute")).clicked() { + app.run("board.timer", json!({"action": "add", "minutes": 1})); + } + if icons::button(ui, Icon::Close, CELL, false, tr("Stop timer")).clicked() { + app.run("board.timer", json!({"action": "stop"})); + } +} + +/// The timer button's popover: presets and a custom length. +fn timer_popup(app: &mut FigApp, anchor: &egui::Response) { + crate::right::menu_popup(anchor).width(300.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 8.0); + ui.add_space(2.0); + ui.label(RichText::new(tr("Timer")).font(semibold(12.0))); + let mut start = None; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + for m in PRESETS { + if widgets::button(ui, &trf("{n} min", &[("n", &m)]), false, true).clicked() { + start = Some(m); + } + } + }); + ui.horizontal(|ui| { + let id = egui::Id::unique("board_timer_minutes"); + let mut minutes: u32 = ui.data(|d| d.get_temp(id)).unwrap_or(5); + widgets::label(ui, tr("Minutes"), Tokens::get(ui.ctx()).text_secondary); + ui.add(egui::DragValue::new(&mut minutes).range(1..=1440).speed(0.25)); + ui.data_mut(|d| d.insert_temp(id, minutes)); + if widgets::button(ui, tr("Start"), true, true).clicked() { + start = Some(minutes); + } + }); + if let Some(minutes) = start { + app.run("board.timer", json!({"action": "start", "minutes": minutes})); + ui.close(); + } + }); +} + +fn vote_part(app: &mut FigApp, ui: &mut Ui, vote: VoteView, editable: bool, t: &Tokens) { + match vote { + VoteView::Off => { + let resp = icons::button(ui, Icon::Vote, CELL, false, tr("Voting session")); + if editable { + vote_popup(app, &resp); + } + } + VoteView::Running { left } => { + let (r, resp) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover()); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::Vote, t.accent_text); + let hint = tr("Click objects to vote; click your badge to take a vote back"); + resp.on_hover_text(hint); + ui.label(RichText::new(trn("{n} votes left", left as usize, &[])).font(regular(12.0)).color(t.text)).on_hover_text(hint); + ui.add_space(4.0); + if editable && widgets::button(ui, tr("End vote"), true, true).clicked() { + app.run("board.vote", json!({"action": "end"})); + } + } + VoteView::Ended { results } => { + let (r, _) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover()); + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::Vote, t.accent_text); + let resp = widgets::button(ui, tr("Results"), false, true); + results_popup(app, &resp, &results, editable); + } + } +} + +/// The vote button's popover: votes per person, what to vote on, Start. +fn vote_popup(app: &mut FigApp, anchor: &egui::Response) { + let selected = !app.session.selection().is_empty(); + crate::right::menu_popup(anchor).width(236.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + ui.spacing_mut().item_spacing.y = 6.0; + ui.add_space(2.0); + ui.label(RichText::new(tr("Voting session")).font(semibold(12.0))); + let (votes_id, on_id) = (egui::Id::unique("board_vote_votes"), egui::Id::unique("board_vote_on")); + let mut votes: u32 = ui.data(|d| d.get_temp(votes_id)).unwrap_or(3); + let mut on: usize = ui.data(|d| d.get_temp(on_id)).unwrap_or(0); + if on == 2 && !selected { + on = 0; + } + ui.horizontal(|ui| { + widgets::label(ui, tr("Votes per person"), Tokens::get(ui.ctx()).text_secondary); + ui.add(egui::DragValue::new(&mut votes).range(1..=99)); + }); + for (i, (_, label)) in SCOPES.iter().enumerate() { + // The selection is offered while there is one. + if (i != 2 || selected) && widgets::radio(ui, on == i, tr(label)) { + on = i; + } + } + ui.data_mut(|d| { + d.insert_temp(votes_id, votes); + d.insert_temp(on_id, on); + }); + let scope = SCOPES.get(on).map_or("all", |s| s.0); + if widgets::button(ui, tr("Start vote"), true, true).clicked() { + app.run("board.vote", json!({"action": "start", "votes": votes, "on": scope})); + ui.close(); + } + }); +} + +/// The ended vote's results, most votes first: a click on a row selects that object. +fn results_popup(app: &mut FigApp, anchor: &egui::Response, results: &[(usize, NodeId, String, u32)], editable: bool) { + crate::right::menu_popup(anchor).width(260.0).show(|ui| { + ui.add_space(2.0); + ui.label(RichText::new(tr("Results")).font(semibold(12.0))); + if results.is_empty() { + widgets::label(ui, tr("No votes yet"), Tokens::get(ui.ctx()).text_secondary); + } + for (rank, id, name, n) in results { + let label = format!("{rank}. {}", widgets::elide(ui, name, 160.0)); + if crate::right::menu_item(ui, &label, &trn("{n} votes", *n as usize, &[])) { + app.run("select.set", json!({"ids": [id]})); + } + } + if editable { + ui.add_space(4.0); + if widgets::button(ui, tr("Clear results"), false, true).clicked() { + app.run("board.vote", json!({"action": "clear"})); + ui.close(); + } + } + }); +} + +/// The vote badges on the page: your votes while the vote runs, everyone's once it ends. +pub fn badges(app: &FigApp, painter: &egui::Painter) { + let Ok(ds) = app.session.ds() else { return }; + let doc = &ds.doc; + let Some(vote) = &doc.vote else { return }; + let counts = if vote.ended { vote.tally(doc, &doc.votes) } else { facilitate::mine(doc, &facilitate::voter(&app.session, &Value::Null)) }; + let t = Tokens::get(painter.ctx()); + let r = BADGE_PX as f32; + for (id, n) in counts { + let Some(b) = doc.world_bounds(id).filter(|_| doc.page_of(id) == Some(ds.page)) else { continue }; + let (c, _) = facilitate::badge(b, ds.view.zoom); + let c = app.to_screen(c.x, c.y); + painter.circle_filled(c + vec2(0.0, 1.0), r + 1.0, Color32::from_black_alpha(40)); + painter.circle_filled(c, r, t.accent); + painter.circle_stroke(c, r, Stroke::new(1.5, Color32::WHITE)); + painter.text(c, Align2::CENTER_CENTER, n.to_string(), semibold(11.0), t.accent_on); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bf893171..ca82afa8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -271,8 +271,10 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } crate::dev_mode::draw(app, &painter); crate::handles::draw(app, &painter); + crate::board_facilitate::badges(app, &painter); crate::board_bar::stamp_author(app, &painter, ctx.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p))); crate::board_bar::show(app, &ctx); + crate::board_facilitate::show(app, &ctx, rect); crate::selection_spacing::draw(app, &painter); crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index eab5914e..f56afc5c 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -212,6 +212,11 @@ icons! { CurvedConnector, Shapes, Stamp, + /// Running a board session: a stopwatch (the timer), two bars (pause) and a ballot going + /// into a box (voting). + Timer, + Pause, + Vote, /// A measurement: a dimension line with arrowheads between two end ticks. Measure, } @@ -1197,6 +1202,22 @@ fn draw(p: &P, icon: Icon) { p.circle(8.0, 8.0, 6.0); p.poly(&[(5.0, 8.3), (7.2, 10.5), (11.0, 6.0)], false); } + Timer => { + p.circle(8.0, 9.0, 5.5); + p.line(8.0, 9.0, 8.0, 6.0); + p.line(6.0, 1.5, 10.0, 1.5); + p.line(8.0, 1.5, 8.0, 3.5); + p.line(12.0, 4.0, 13.5, 2.5); + } + Pause => { + p.fill_rect(4.0, 3.0, 6.5, 13.0, 0.5); + p.fill_rect(9.5, 3.0, 12.0, 13.0, 0.5); + } + Vote => { + p.rect(2.5, 8.5, 13.5, 13.5, 1.0); + p.poly(&[(5.5, 8.5), (5.5, 2.5), (10.5, 2.5), (10.5, 8.5)], false); + p.poly(&[(6.6, 5.6), (7.7, 6.7), (9.4, 4.6)], false); + } Measure => { p.line(2.5, 4.0, 2.5, 12.0); p.line(13.5, 4.0, 13.5, 12.0); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e1fc5fa..ccfe6020 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ pub mod a11y; pub mod assets_panel; pub mod bidi; pub mod board_bar; +pub mod board_facilitate; pub mod branch_ui; pub mod canvas; pub mod cjk; diff --git a/docs/boards-parity.md b/docs/boards-parity.md index ca358076..e5f6cd6e 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,13 +1,13 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) -**Breadth ~37%, ready ~21% (estimated, erring low).** A board is a FigCraft file of its own kind +**Breadth ~42%, ready ~25% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that follow what they join (straight, elbow or curved, their ends dragged onto other objects), marker, -highlighter and eraser, stamps, text and sections, edited together in the existing live sessions. -The facilitation tools (timer, voting, widgets), templates, tables, mind maps and the rest of the -tail are missing. Remaining: ~28–56 h. Tracked as area 22 of +highlighter and eraser, stamps, text and sections, edited together in the existing live sessions, +with a shared countdown and voting sessions to run a workshop. Music, board widgets, templates, +tables, mind maps and the rest of the tail are missing. Remaining: ~24–50 h. Tracked as area 22 of [`target-app-parity.md`](target-app-parity.md) (not in its weighted sum: the target there is the design product). The work item is gap 24 in [`gaps.md`](gaps.md). @@ -44,8 +44,10 @@ all end with the same geometry. | Stamps sticking to the object they land on, the stamp wheel, emotes, stickers | missing | | | Text and sections | done | the existing Text tool and sections | | Live sessions: cursors, selections, cursor chat, spotlight | done | the existing sessions; board objects, connectors following moved objects, colours and stamps sync both ways (checked with two app instances) | -| Timer and music | missing | | -| Voting sessions | missing | | +| Timer | done | a pill at the top right of the board: presets (1, 3, 5, 10, 15 min) or any whole minutes, pause, resume, a minute more, stop; red at 0:00 until stopped; no sound. `board.timer {action, minutes?}`, `board.timerState`. Synced as its end time (or the time left while paused), so every client counts down on its own clock: nothing ticks over the network. Not an undo step | +| Timer music, timer sounds | missing | | +| Voting sessions | done | one at a time: votes per person (1–99), on every object, on sticky notes or on the selection; a click on an object places a vote (several on one object allowed), a click on your badge or an Alt-click takes it back; your own votes show as badges while it runs, everyone's once it ends, with a ranked list (ties share a rank) that selects an object from its row; Clear results. `board.vote {action: start\|cast\|retract\|end\|clear}`, `board.voteResults`. Ballots sync per voter (keyed by the live-session client id, or your name offline), so people voting at once never overwrite each other. Not undo steps | +| Voting: only the facilitator ends or clears it, who voted for what | missing | anyone who can edit the board can end or clear a vote; results are counts only | | Board widgets | missing | design-file widgets exist (`crates/engine/src/widgets.rs`), not wired to boards | | Templates (retrospective, brainstorm, flow chart…) | missing | | | Tables | missing | | @@ -61,4 +63,5 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| | 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h | +| 2026-10-24 | minor | Facilitation (M12): a shared timer and voting sessions. Breadth ~35% → ~40%, ready ~20% → ~24%, ~30–60 h → ~26–54 h | | 2026-10-24 | major | Created with board files (M12): sticky notes, shapes with text, connectors, ink and eraser, stamps, the board toolbar and contextual bar, live-session sync | diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 38ef96ec..061fd728 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -142,7 +142,11 @@ As built: colour profile, image presets, annotation categories and library data (whole values); styles, comment threads, variable collections and variables (one entry per id plus the order of ids; the whole list when ids aren't unique); dev-mode snapshots (per layer); images (per content - hash, the encoded bytes as base64). The page order is the pages' positions. + hash, the encoded bytes as base64); a board's timer and vote (whole values, `DocProp::Timer` and + `DocProp::Vote`) and the vote's ballots (`Entry::Ballot(voter)`, one per voter, so people voting + at once write different entries). The timer travels as its end time (or, paused, the time left), + never as ticks: each client counts down on its own clock. The page order is the pages' + positions. - **Next**: this file's published library (it embeds a snapshot document; it moves with an asset channel, together with images, which then stop travelling inside batches); the version history (kept beside the document, not in it) on the standalone server; finer entries for modes, diff --git a/docs/gaps.md b/docs/gaps.md index d9203349..03c0d461 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-25 · **Change:** minor (gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); 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-25 · **Change:** minor (gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); 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 @@ -37,7 +37,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | | 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | | 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | -| 24 | Boards: the whiteboard tail (timer, voting, widgets, templates, tables, mind maps…) | Feature | 28–56 | [boards-parity](boards-parity.md) | +| 24 | Boards: the whiteboard tail (widgets, templates, tables, mind maps…) | Feature | 24–50 | [boards-parity](boards-parity.md) | ## Entries @@ -475,17 +475,18 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 24. Boards: the whiteboard tail - **Landed (2026-10-24, M12):** board files (`file.newBoard`) with sticky notes, shapes with text, connectors that re-route when what they join moves, marker, highlighter and eraser, stamps, text - and sections; a board toolbar and a contextual bar; live sessions sync it all. + and sections; a board toolbar and a contextual bar; live sessions sync it all. Then (also M12) a + timer everyone on the board sees and voting sessions with per-person caps, hidden votes and + ranked results (`board.timer`, `board.vote`). - **Landed (2026-10-24, M12):** a dot grid on board pages, under the layers; a selected connector's ends drag onto another object or empty canvas (`board.reattach`); curved connectors; elbows that go around the objects they join. -- **Missing:** timer and music, voting sessions, board widgets, templates, tables, mind maps, washi - tape, code blocks, audio and huddles, quick-create handles, connector waypoints placed by hand, - stamps sticking to the objects they land on, `.jam` files - ([`boards-parity.md`](boards-parity.md)). -- **User impact:** boards cover a brainstorm or a flow sketch; facilitated workshops need the - timer, voting and templates. -- **Estimate:** 28–56 h. +- **Missing:** timer music, board widgets, templates, tables, mind maps, washi tape, code blocks, + audio and huddles, quick-create handles, connector waypoints placed by hand, stamps sticking to + the objects they land on, `.jam` files ([`boards-parity.md`](boards-parity.md)). +- **User impact:** boards cover a brainstorm, a flow sketch and a timed, voted workshop round; + workshops still need templates. +- **Estimate:** 24–50 h. ## Revision history @@ -495,6 +496,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged | | 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged | | 2026-10-24 | minor | Gap 16: Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | +| 2026-10-24 | minor | Gap 24: a shared board timer and voting sessions landed (M12); 30–60 h → 26–54 h, ranking unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 1a1f3a75..22ee513d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -430,6 +430,40 @@ one undo step and returns the ids it made: `{"id": 4, "text": 5}` in a new board without an author name; a sticky with its author's name takes one more id). In a `batch`, step references (`{"from": {"$step": 1}, "to": {"$step": 2}}`, the `id`s steps 1 and 2 returned) avoid counting. +Running a session on a board: a countdown and votes. Both are part of the board, so everyone in a +live session sees the same, and neither is an undo step (undo leaves them as they are). On a +design file they refuse. + +- `board.timer {action: start|pause|resume|add|stop, minutes?}` → `{state, left, text, ends}`: one + countdown per board. `start` takes `minutes` (default 5) and `add` adds them (default 1), from + one second up to 1440 minutes in all; `pause` keeps the time left, `resume` counts it down again. + The timer keeps its end time (milliseconds since 1970) or, paused, the time left, and each + client counts down on its own clock. `board.timerState {}` reads it: `state` is `off`, + `running`, `paused` or `done` (it ran out: 0:00 until someone stops it), `left` milliseconds, + `text` as `m:ss` (or `h:mm:ss`). +- `board.vote {action: start|cast|retract|end|clear, …}` → the vote as `board.voteResults` gives + it. One vote at a time: `start {votes? (per person, 1–99, default 3), on?: all|stickies|selection + (default all), ids? (with selection; default the selected layers)}` (a vote that ended may be + replaced; one still running is refused), `cast {id}` puts one of your votes on an object (several + on the same object are fine; once yours are used up it refuses), `retract {id}` takes one back, + `end` shows everyone's votes, `clear` removes the vote and its results. `voter` (any action) + votes as someone else, for scripts and tests; by default you are your live-session client, or + your name (`pref.userName`) when the file isn't shared. In the app a click on an object votes + while a vote runs, and a click on your badge (or an Alt-click) takes the vote back. +- `board.voteResults {voter?}` → `{state: off|running|ended, votesPerPerson, on, by, left, mine: + [{id, votes}], voters, results?}`: while it runs you see your own votes only (`mine`, `left`) and + how many have voted; once it ends `results` lists `[{id, name, votes, rank}]`, most votes first + (ties share a rank), with `name` the sticky's text or the layer's name. Votes on deleted objects + don't count. + +```json +{"command": "board.timer", "params": {"action": "start", "minutes": 10}} +{"command": "board.vote", "params": {"action": "start", "votes": 2, "on": "stickies"}} +{"command": "board.vote", "params": {"action": "cast", "id": 2}} +{"command": "board.vote", "params": {"action": "end"}} +{"command": "board.voteResults", "params": {}} +``` + ### Sections, handoff and arrow keys `run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or diff --git a/docs/roadmap.md b/docs/roadmap.md index 503b514d..59deea4c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -60,7 +60,7 @@ blockers. | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | | M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 | | M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | -| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions | timer, voting, widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 30–60 | +| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 24–50 | | M13 AI features (new, owner decision) | not started | local models | 30–60 | ## Next, after the current focus @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | +| 2026-10-24 | minor | M12: a shared board timer and voting sessions; 30–60 h → 26–54 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Milestone M12 Boards added; AI features move to M13 | | 2026-10-21 | minor | M6: best-effort `.sketch` import (Sketch 48 and newer), see [file-format-parity](file-format-parity.md). Percentages unchanged | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dc3ce78b..86cf7913 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -117,7 +117,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| 22 | Boards (whiteboarding) | 0 | ~37% | ~21% | Board files with a dot grid, sticky notes, shapes with text, connectors that follow what they join (straight, elbow around the objects, or curved; ends dragged to re-attach), marker, highlighter and eraser, stamps, text and sections, in live sessions (M12); missing: timer, voting, board widgets, templates, tables, mind maps, washi tape, audio, quick-create handles, connector waypoints, stamps sticking to objects, `.jam` files ([boards-parity](boards-parity.md)). Not in the weighted sum | 28–56 | +| 22 | Boards (whiteboarding) | 0 | ~42% | ~25% | Board files with a dot grid, sticky notes, shapes with text, connectors that follow what they join (straight, elbow around the objects, or curved; ends dragged to re-attach), marker, highlighter and eraser, stamps, text and sections, a shared timer and voting sessions, in live sessions (M12); missing: timer music, board widgets, templates, tables, mind maps, washi tape, audio, quick-create handles, connector waypoints, stamps sticking to objects, `.jam` files ([boards-parity](boards-parity.md)). Not in the weighted sum | 24–50 | | | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** | | | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 | @@ -643,6 +643,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| | 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | +| 2026-10-24 | minor | Boards (area 22, weight 0): a shared timer and voting sessions (M12); ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; weighted numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) | | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | | 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged | From 11cf6fb12a02f07793279d1de94cff8b82e2e22a Mon Sep 17 00:00:00 2001 From: Pedro Monteiro <83892026+peda-cos@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:34:04 -0300 Subject: [PATCH 09/15] fix: fsync the files directory after creating a new files folder (#1093) --- apps/figcraft-server/src/store.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 31b877cb..1ea420a5 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -658,7 +658,10 @@ impl Store { let dir = self.dir(&id); let written = fs::create_dir_all(&dir) .map_err(|e| e.to_string()) - .and_then(|()| disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())) + .and_then(|()| { + disk::sync_dir(Some(&self.root.join(FILES))); + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()) + }) .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) // Last: a folder without its description is an upload cut short. From d3782fdc430a502c41b464b2a1deca68315e75cc Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 26 Oct 2026 12:52:39 +0700 Subject: [PATCH 10/15] M12: board templates and tables (#1363) * M12: board templates and tables Five built-in board templates (retrospective, brainstorm, weekly planner, flow chart, kanban board) placed as ordinary board objects in one undo step (board.template, board.templates) from a toolbar picker with pictures, and board tables made of auto layout frames (board.table, board.tableEdit; up to 50 x 20), typed into with a double-click and edited by row and column from the bar over the selection. Docs: boards-parity, gap 24, ROADMAP, mcp. * M12: board templates picker on egui 0.37 Id::new and Ui::id are deprecated in egui 0.37: the templates picker's area uses Id::unique, its buttons' interaction ids come from ui.scope_id(), and its test reads the board bar's area by Id::unique. --- ROADMAP.md | 6 +- crates/doc/src/board.rs | 6 +- crates/engine/src/board.rs | 9 +- crates/engine/src/board_table.rs | 300 +++++++++++++++++++++++ crates/engine/src/board_tables_tests.rs | 257 +++++++++++++++++++ crates/engine/src/board_templates.rs | 261 ++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/lib.rs | 4 + crates/ui-egui/locales/bn.toml | 13 + crates/ui-egui/locales/cs.toml | 13 + crates/ui-egui/locales/de.toml | 14 ++ crates/ui-egui/locales/en.toml | 9 + crates/ui-egui/locales/es-419.toml | 2 + crates/ui-egui/locales/es.toml | 15 +- crates/ui-egui/locales/fi.toml | 13 + crates/ui-egui/locales/fr.toml | 13 + crates/ui-egui/locales/id.toml | 13 + crates/ui-egui/locales/ja.toml | 13 + crates/ui-egui/locales/ka.toml | 13 + crates/ui-egui/locales/ko.toml | 14 ++ crates/ui-egui/locales/nl.toml | 13 + crates/ui-egui/locales/pl.toml | 13 + crates/ui-egui/locales/pt-BR.toml | 13 + crates/ui-egui/locales/ru.toml | 13 + crates/ui-egui/locales/sv.toml | 13 + crates/ui-egui/locales/tr.toml | 13 + crates/ui-egui/locales/zh-Hans.toml | 14 ++ crates/ui-egui/src/board_bar.rs | 12 +- crates/ui-egui/src/board_picker.rs | 162 ++++++++++++ crates/ui-egui/src/board_picker_tests.rs | 83 +++++++ crates/ui-egui/src/i18n_tests.rs | 1 + crates/ui-egui/src/icons.rs | 38 +++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/toolbar.rs | 3 +- crates/ui-egui/src/windows_tests.rs | 4 +- docs/boards-parity.md | 16 +- docs/gaps.md | 22 +- docs/mcp.md | 11 + docs/roadmap.md | 2 +- docs/target-app-parity.md | 5 +- 40 files changed, 1413 insertions(+), 31 deletions(-) create mode 100644 crates/engine/src/board_table.rs create mode 100644 crates/engine/src/board_tables_tests.rs create mode 100644 crates/engine/src/board_templates.rs create mode 100644 crates/ui-egui/src/board_picker.rs create mode 100644 crates/ui-egui/src/board_picker_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index cf505389..50a3446b 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-25 · **Change:** minor (boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -93,7 +93,7 @@ native speakers' reviews of the nine language drafts need people. | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | -| Boards (whiteboarding; not in the weighted numbers) | ~42% | ~25% | 24–50 | [boards-parity](docs/boards-parity.md) | +| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) | ## Languages @@ -138,6 +138,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged. - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). +- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. @@ -346,6 +347,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | +| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | diff --git a/crates/doc/src/board.rs b/crates/doc/src/board.rs index ab943d2a..f7367db3 100644 --- a/crates/doc/src/board.rs +++ b/crates/doc/src/board.rs @@ -57,13 +57,16 @@ pub enum BoardItem { }, /// A marker or highlighter stroke (what the eraser takes). Ink, + /// A table: a frame of row frames holding cell frames with text (its column lines are thin + /// rectangles laid over it). + Table, } impl BoardItem { /// Objects selected and moved as a whole: a click on their text or outline picks them, and /// they never take other layers in. pub fn is_atom(&self) -> bool { - matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. }) + matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table) } /// Objects whose first text layer is their text (a double-click or Enter edits it). @@ -109,6 +112,7 @@ impl BoardItem { BoardItem::Label { .. } => "label", BoardItem::Stamp { .. } => "stamp", BoardItem::Ink => "ink", + BoardItem::Table => "table", } } } diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs index c56d52a1..5408c6c9 100644 --- a/crates/engine/src/board.rs +++ b/crates/engine/src/board.rs @@ -34,7 +34,7 @@ const STICKY_GAP: f64 = 24.0; /// A new shape's size. const SHAPE: f64 = 160.0; /// Text on board objects, and the author line under a sticky's note. -const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); +pub(crate) const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); /// Connectors' default colour and weight. const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); @@ -285,7 +285,7 @@ fn soft_shadow() -> Effect { } /// A text layer filling its frame's width (the frame's auto layout places it). -fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { +pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; let mut td = TextData { @@ -774,7 +774,7 @@ fn set_color(s: &mut Session, p: &Value) -> Result { n.strokes = vec![Paint::solid(line_shade(color))]; } BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], - BoardItem::Label { .. } | BoardItem::Stamp { .. } => continue, + BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue, } changed.push(id); } @@ -1472,7 +1472,7 @@ impl Session { /// label to type in). True when it was one. pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); - let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| owner_of_text(doc, te.id) == Some(whole)); + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id)); let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; self.tool.gesture = Gesture::None; self.cancel_interaction()?; @@ -1487,6 +1487,7 @@ impl Session { }; edit_text(self, label) } + BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf), b if b.has_text() => edit_text(self, whole), _ => Ok(false), } diff --git a/crates/engine/src/board_table.rs b/crates/engine/src/board_table.rs new file mode 100644 index 00000000..9cf35dcb --- /dev/null +++ b/crates/engine/src/board_table.rs @@ -0,0 +1,300 @@ +//! Board tables: a frame of row frames holding cell frames with text, all ordinary auto layout +//! layers. The table hugs its rows' height, the rows share its width and the cells share their +//! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws +//! a line under itself and the column lines are thin rectangles over the table that stretch with +//! it (they are rebuilt after every edit, like the first row's header tint). + +use figcraft_color::{Color, Paint}; +use figcraft_doc::{ + AutoLayout, BoardItem, Constraints, Document, HConstraint, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, TextAlignH, VConstraint, +}; +use figcraft_geom::Affine; +use serde_json::{Value, json}; + +use crate::board::{INK, add_text, require}; +use crate::cmd::CommandSpec; +use crate::params::{get, id, num, num_or, opt_index, req_string}; +use crate::{EngineError, Result, Session, bad, spec, textedit}; + +/// The most rows and columns a table has. +pub const MAX_ROWS: usize = 50; +pub const MAX_COLUMNS: usize = 20; +/// A new column's width, and a cell's height before its text is measured. +const CELL_W: f64 = 160.0; +const CELL_H: f64 = 40.0; +/// Room around a cell's text: [top, right, bottom, left]. +const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0]; +const TEXT_SIZE: f64 = 14.0; +const LINE_W: f64 = 1.0; +const GRID: Color = Color::from_rgb8_const(0xCF, 0xCB, 0xC4); +const HEADER: Color = Color::from_rgb8_const(0xF1, 0xEF, 0xEB); + +pub fn specs() -> Vec { + vec![ + spec!( + "board.table", + "Table", + "{rows?, columns? (default 3 × 3, or the size of `cells`; at most 50 × 20), cells? ([[text…]…] row by row), x?, y? (its top-left on the page; default centred in the view)} → {id, rows, columns, cells: [[text id…]…]}", + true, + table_cmd + ), + spec!( + "board.tableEdit", + "Edit table", + "{id? (a table or a layer in one; default the selection), action: addRow|addColumn|removeRow|removeColumn, index? (where the new one goes, or which one goes; default the last)} → {id, rows, columns, cells}", + true, + edit_cmd + ), + ] +} + +/// A count from 1 to `max` (`default` when missing). +fn count(cmd: &str, p: &Value, key: &str, default: usize, max: usize) -> Result { + match num(p, key) { + None if get(p, key).is_none() => Ok(default), + Some(v) if v.fract() == 0.0 && (1.0..=max as f64).contains(&v) => Ok(v as usize), + _ => Err(bad(cmd, format!("`{key}` is a whole number from 1 to {max}"))), + } +} + +/// A frame for the table at `index` in `parent`, made by `f`. +fn frame(doc: &mut Document, parent: NodeId, index: Option, name: &str, size: (f64, f64), f: impl FnOnce(&mut Node)) -> Result { + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Frame, name, size.0, size.1); + n.fills.clear(); + n.clip = false; + f(&mut n); + doc.insert(n, parent, index)?; + Ok(id) +} + +fn make_cell(doc: &mut Document, row: NodeId, index: Option, width: f64, text: &str) -> Result { + let cell = frame(doc, row, index, "Cell", (width, CELL_H), |n| { + n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: CELL_PAD, ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Fill; + n.child_layout.sizing_v = Sizing::Hug; + n.child_layout.min_h = Some(CELL_H); + })?; + add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?; + Ok(cell) +} + +fn make_row(doc: &mut Document, table: NodeId, index: Option, width: f64, texts: &[&str]) -> Result { + let row = frame(doc, table, index, "Row", (width, CELL_H), |n| { + n.layout = Some(AutoLayout { mode: LayoutMode::Horizontal, gap: 0.0, padding: [0.0; 4], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Fill; + n.child_layout.sizing_v = Sizing::Hug; + n.stroke.weight = LINE_W; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, LINE_W, 0.0]); + })?; + let cw = width / texts.len().max(1) as f64; + for t in texts { + make_cell(doc, row, None, cw, t)?; + } + Ok(row) +} + +/// A table of `texts` (rows of cell texts, all as long) at `at` on `parent`. +pub(crate) fn make_table(doc: &mut Document, parent: NodeId, at: (f64, f64), texts: &[Vec<&str>]) -> Result { + let columns = texts.first().map_or(1, Vec::len).max(1); + let w = CELL_W * columns as f64; + let table = frame(doc, parent, None, "Table", (w, CELL_H * texts.len() as f64), |n| { + n.transform = Affine::translate(at); + n.fills = vec![Paint::solid(Color::WHITE)]; + n.strokes = vec![Paint::solid(GRID)]; + n.stroke.weight = LINE_W; + n.stroke.align = StrokeAlign::Inside; + n.corners.radii = [4.0; 4]; + n.clip = true; + // The outline draws over the rows' edges rather than taking room from them. + n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: [0.0; 4], strokes_included: false, ..AutoLayout::default() }); + n.child_layout.sizing_v = Sizing::Hug; + n.board = Some(BoardItem::Table); + })?; + for row in texts { + make_row(doc, table, None, w, row)?; + } + restyle(doc, table)?; + Ok(table) +} + +/// Table `table`'s rows (its laid-out children) and column lines (the others). +fn parts(doc: &Document, table: NodeId) -> (Vec, Vec) { + let kids = doc.node(table).map(|n| n.children.clone()).unwrap_or_default(); + kids.into_iter().partition(|c| doc.node(*c).is_some_and(|n| !n.child_layout.absolute)) +} + +fn cells(doc: &Document, row: NodeId) -> Vec { + doc.node(row).map(|n| n.children.clone()).unwrap_or_default() +} + +/// The first row tinted, a line under every row but the last, and the column lines over it all. +fn restyle(doc: &mut Document, table: NodeId) -> Result<()> { + let (rows, lines) = parts(doc, table); + for l in lines { + doc.remove(l)?; + } + let last = rows.len().saturating_sub(1); + for (i, r) in rows.iter().enumerate() { + let n = doc.get_mut(*r)?; + n.fills = if i == 0 { vec![Paint::solid(HEADER)] } else { Vec::new() }; + n.strokes = if i < last { vec![Paint::solid(GRID)] } else { Vec::new() }; + } + let columns = rows.first().map_or(0, |r| cells(doc, *r).len()); + let (w, h) = doc.get(table).map(|t| (t.width, t.height))?; + for i in 1..columns { + let x = w * i as f64 / columns as f64 - LINE_W / 2.0; + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Rectangle, "Column line", LINE_W, h); + n.fills = vec![Paint::solid(GRID)]; + n.transform = Affine::translate((x, 0.0)); + n.child_layout.absolute = true; + n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::TopBottom }; + n.locked = true; + doc.insert(n, table, None)?; + } + Ok(()) +} + +/// Each cell's text layer, row by row (null for a cell without one). +fn cell_texts(doc: &Document, table: NodeId) -> Value { + let text = |cell: NodeId| { + doc.node(cell).and_then(|n| n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_))))) + }; + let (rows, _) = parts(doc, table); + Value::Array(rows.iter().map(|r| Value::Array(cells(doc, *r).into_iter().map(|c| json!(text(c))).collect())).collect()) +} + +fn answer(doc: &Document, table: NodeId) -> Value { + let (rows, _) = parts(doc, table); + let columns = rows.first().map_or(0, |r| cells(doc, *r).len()); + json!({"id": table, "rows": rows.len(), "columns": columns, "cells": cell_texts(doc, table)}) +} + +fn table_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.table"; + require(s, cmd)?; + let given: Vec> = match get(p, "cells") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(rows)) => rows + .iter() + .map(|r| match r { + Value::Array(c) => c.iter().map(|t| t.as_str().map(str::to_string).ok_or_else(|| bad(cmd, "`cells` holds text"))).collect(), + _ => Err(bad(cmd, "`cells` is a list of rows, each a list of texts")), + }) + .collect::>()?, + Some(_) => return Err(bad(cmd, "`cells` is a list of rows, each a list of texts")), + }; + let widest = given.iter().map(Vec::len).max().unwrap_or(0); + let rows = count(cmd, p, "rows", if given.is_empty() { 3 } else { given.len() }, MAX_ROWS)?; + let columns = count(cmd, p, "columns", if widest == 0 { 3 } else { widest }, MAX_COLUMNS)?; + if given.len() > rows || widest > columns { + return Err(bad(cmd, format!("`cells` doesn't fit in {rows} × {columns}"))); + } + let texts: Vec> = + (0..rows).map(|r| (0..columns).map(|c| given.get(r).and_then(|row| row.get(c)).map_or("", String::as_str)).collect()).collect(); + let (w, h) = (CELL_W * columns as f64, CELL_H * rows as f64); + let view = &s.ds()?.view; + let at = (num_or(p, "x", view.cx - w / 2.0), num_or(p, "y", view.cy - h / 2.0)); + if !(at.0.is_finite() && at.1.is_finite()) { + return Err(bad(cmd, "`x` and `y` are finite numbers")); + } + let page = s.page()?; + let doc = s.doc_mut()?; + let table = make_table(doc, page, at, &texts)?; + let out = answer(doc, table); + s.set_selection(vec![table]); + Ok(out) +} + +/// The table `id` is or is in. +fn table_of(doc: &Document, id: NodeId) -> Option { + std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table))) +} + +fn edit_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.tableEdit"; + require(s, cmd)?; + let target = id(p, "id").or_else(|| s.selection().first().copied()).ok_or_else(|| EngineError::Disabled(cmd.into(), "select a table".into()))?; + let doc = s.doc()?; + doc.get(target)?; + let table = table_of(doc, target).ok_or_else(|| bad(cmd, "that isn't a table"))?; + let action = req_string(cmd, p, "action")?; + let index = opt_index(cmd, p, "index")?; + let (rows, _) = parts(doc, table); + let columns = rows.first().map_or(0, |r| cells(doc, *r).len()).max(1); + let width = doc.get(table)?.width; + let doc = s.doc_mut()?; + // `n` things: `index` (default `last`) must be at most `end`. + let at = |end: usize, last: usize| match index { + Some(i) if i > end => Err(bad(cmd, format!("`index` is from 0 to {end}"))), + Some(i) => Ok(i), + None => Ok(last), + }; + match action.as_str() { + "addRow" => { + if rows.len() >= MAX_ROWS { + return Err(bad(cmd, format!("a table has at most {MAX_ROWS} rows"))); + } + let i = at(rows.len(), rows.len())?; + // Rows come before the column lines among the table's layers. + let child = rows.get(i).or(rows.last()).and_then(|r| doc.node(table).and_then(|t| t.children.iter().position(|c| c == r))); + let child = child.map(|c| if i >= rows.len() { c + 1 } else { c }); + make_row(doc, table, child, width, &vec![""; columns])?; + } + "removeRow" => { + if rows.len() <= 1 { + return Err(bad(cmd, "a table keeps at least one row")); + } + let i = at(rows.len() - 1, rows.len() - 1)?; + if let Some(r) = rows.get(i) { + doc.remove(*r)?; + } + } + "addColumn" => { + if columns >= MAX_COLUMNS { + return Err(bad(cmd, format!("a table has at most {MAX_COLUMNS} columns"))); + } + let i = at(columns, columns)?; + let cw = width / columns as f64; + doc.get_mut(table)?.width = width + cw; + for r in &rows { + make_cell(doc, *r, Some(i), cw, "")?; + } + } + "removeColumn" => { + if columns <= 1 { + return Err(bad(cmd, "a table keeps at least one column")); + } + let i = at(columns - 1, columns - 1)?; + doc.get_mut(table)?.width = width - width / columns as f64; + for r in &rows { + if let Some(c) = cells(doc, *r).get(i) { + doc.remove(*c)?; + } + } + } + other => return Err(bad(cmd, format!("`action` is addRow, addColumn, removeRow or removeColumn, not `{other}`"))), + } + restyle(doc, table)?; + let out = answer(doc, table); + s.set_selection(vec![table]); + Ok(out) +} + +/// A double-click on a table: type in the cell under the pointer (`leaf`). False when it hit no +/// cell (a column line, the table's edge). +pub(crate) fn edit_cell(s: &mut Session, doc: &Document, leaf: NodeId) -> Result { + let in_table = |c: &NodeId| { + let row = doc.node(*c).and_then(|n| n.parent); + row.and_then(|r| doc.node(r)).and_then(|r| r.parent).and_then(|t| doc.node(t)).is_some_and(|t| t.board == Some(BoardItem::Table)) + }; + let Some(cell) = std::iter::once(leaf).chain(doc.ancestors(leaf)).find(in_table) else { return Ok(false) }; + let Some(text) = cells(doc, cell).into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) else { + return Ok(false); + }; + textedit::begin(s, text, None, false)?; + textedit::key(s, "SelectAll", false, false, false)?; + Ok(true) +} diff --git a/crates/engine/src/board_tables_tests.rs b/crates/engine/src/board_tables_tests.rs new file mode 100644 index 00000000..2c0d07bb --- /dev/null +++ b/crates/engine/src/board_tables_tests.rs @@ -0,0 +1,257 @@ +//! Boards: templates (each placed as board objects in one undo step) and tables (made from auto +//! layout frames, edited by rows and columns, typed into with the text tools, synced live). + +use figcraft_doc::{BoardItem, NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::board_tests::{board, id, node}; +use crate::collab_live_tests::Room; +use crate::tools::{PointerEvent, PointerKind}; +use crate::{EngineError, Session}; + +fn count(s: &Session, f: impl Fn(&BoardItem) -> bool) -> usize { + s.doc().unwrap().nodes().filter(|n| n.board.as_ref().is_some_and(&f)).count() +} + +fn ids(v: &Value, key: &str) -> Vec { + v[key].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect() +} + +/// Rows of cell texts' characters. +fn texts(s: &Session, table: &Value) -> Vec> { + let chars = |v: &Value| match &node(s, NodeId(v.as_u64().unwrap())).kind { + NodeKind::Text(td) => td.characters.clone(), + _ => panic!("not text"), + }; + table["cells"].as_array().unwrap().iter().map(|r| r.as_array().unwrap().iter().map(chars).collect()).collect() +} + +/// A table's column lines (its laid-out-over children): their x and height. +fn lines(s: &Session, table: NodeId) -> Vec<(f64, f64)> { + let doc = s.doc().unwrap(); + let t = doc.node(table).unwrap(); + t.children.iter().map(|c| doc.node(*c).unwrap()).filter(|n| n.child_layout.absolute).map(|n| (n.transform.translation().x, n.height)).collect() +} + +#[test] +fn every_template_places_board_objects_in_one_undo_step() { + let mut s = board(); + let list = s.execute("board.templates", &json!({})).unwrap(); + let names: Vec<&str> = list.as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap()).collect(); + assert_eq!(names, ["retrospective", "brainstorm", "weeklyPlanner", "flowChart", "kanban"]); + for (i, name) in names.iter().enumerate() { + let before = s.doc().unwrap().nodes().count(); + let r = s.execute("board.template", &json!({"name": name, "x": i as f64 * 2000.0, "y": 0})).unwrap(); + let made = ids(&r, "ids"); + assert!(!made.is_empty(), "{name}"); + assert_eq!(s.selection(), made, "{name}: what it placed is selected"); + let doc = s.doc().unwrap(); + // It sits where it was asked to, about its listed size. + let b = doc.bounds_of(&made).unwrap(); + assert!((b.x0 - i as f64 * 2000.0).abs() < 1.0 && b.y0.abs() < 1.0, "{name} at {b:?}"); + let size = &list[i]; + assert!((b.width() - size["width"].as_f64().unwrap()).abs() < 2.0, "{name}: width {}", b.width()); + assert!(b.height() <= size["height"].as_f64().unwrap() + 2.0, "{name}: height {}", b.height()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().nodes().count(), before, "{name}: one undo step"); + s.execute("edit.redo", &json!({})).unwrap(); + } + // By name too, centred in the view without a place. + let view = s.ds().unwrap().view.clone(); + let r = s.execute("board.template", &json!({"name": "Kanban board"})).unwrap(); + let b = s.doc().unwrap().bounds_of(&ids(&r, "ids")).unwrap(); + assert!((b.center().x - view.cx).abs() < 2.0, "{b:?} centred on {}", view.cx); + assert!(s.execute("board.template", &json!({"name": "party"})).is_err()); + assert!(s.execute("board.template", &json!({})).is_err()); + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + assert!(matches!(d.execute("board.template", &json!({"name": "kanban"})), Err(EngineError::Disabled(..))), "boards only"); + assert!(matches!(d.execute("board.table", &json!({})), Err(EngineError::Disabled(..))), "boards only"); +} + +#[test] +fn templates_are_made_of_ordinary_board_objects() { + let mut s = board(); + s.execute("board.template", &json!({"name": "retrospective", "x": 0, "y": 0})).unwrap(); + let doc = s.doc().unwrap(); + let sections: Vec<_> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Section { .. })).collect(); + assert_eq!(sections.iter().map(|n| n.name.as_str()).collect::>(), ["Went well", "To improve", "Next steps"]); + for sec in §ions { + let stickies = sec.children.iter().filter(|c| matches!(doc.node(**c).unwrap().board, Some(BoardItem::Sticky { .. }))).count(); + assert_eq!(stickies, 2, "{}", sec.name); + } + // No author on a template's stickies. + assert!(doc.nodes().filter(|n| matches!(n.board, Some(BoardItem::Sticky { .. }))).all(|n| n.board.as_ref().unwrap().author().is_empty())); + // The flow chart's connectors join its shapes, labelled at the decision. + s.execute("board.template", &json!({"name": "flowChart", "x": 0, "y": 1000})).unwrap(); + let doc = s.doc().unwrap(); + let joined: Vec<_> = doc + .nodes() + .filter_map(|n| match &n.board { + Some(BoardItem::Connector(c)) => Some((c.from.node().unwrap(), c.to.node().unwrap())), + _ => None, + }) + .collect(); + assert_eq!(joined.len(), 6); + assert!(joined.iter().all(|(a, b)| matches!(doc.node(*a).unwrap().board, Some(BoardItem::Shape { .. })) + && matches!(doc.node(*b).unwrap().board, Some(BoardItem::Shape { .. })))); + assert_eq!(count(&s, |b| matches!(b, BoardItem::Label { .. })), 2); + // The brainstorm's topic is joined to each of its four clusters. + s.execute("board.template", &json!({"name": "brainstorm", "x": 3000, "y": 0})).unwrap(); + assert_eq!(count(&s, |b| matches!(b, BoardItem::Connector(_))), 10); +} + +#[test] +fn a_table_is_auto_layout_frames_with_a_header_and_column_lines() { + let mut s = board(); + let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [["Name", "Role"], ["Ada", "Lead"]], "rows": 3})).unwrap(); + let t = id(&r, "id"); + assert_eq!((r["rows"].as_u64(), r["columns"].as_u64()), (Some(3), Some(2))); + assert_eq!(texts(&s, &r), [["Name", "Role"], ["Ada", "Lead"], ["", ""]]); + assert_eq!(node(&s, t).board, Some(BoardItem::Table)); + assert_eq!(s.selection(), [t]); + let table = node(&s, t); + assert_eq!(table.width, 320.0); + let rows: Vec<_> = table.children.iter().map(|c| node(&s, *c)).filter(|n| !n.child_layout.absolute).collect(); + assert_eq!(rows.len(), 3); + assert!(rows[0].fills.len() == 1 && rows[1].fills.is_empty(), "the header row is tinted"); + assert!(rows[1].strokes.len() == 1 && rows[2].strokes.is_empty(), "a line under every row but the last"); + let cell = node(&s, rows[1].children[1]); + assert_eq!((cell.width, cell.transform.translation().x), (160.0, 160.0), "cells share their row"); + assert_eq!(table.height, rows.iter().map(|r| r.height).sum::(), "the table hugs its rows"); + assert_eq!(lines(&s, t), [(159.5, table.height)], "the column line runs the table's height"); + // Caps and refusals. + for bad in + [json!({"rows": 51}), json!({"columns": 21}), json!({"rows": 0}), json!({"rows": 2.5}), json!({"cells": [["a", "b", "c"]], "columns": 2})] + { + assert!(s.execute("board.table", &bad).is_err(), "{bad}"); + } + assert!(s.execute("board.table", &json!({"rows": 50, "columns": 20})).is_ok(), "the largest table"); + // It saves and reopens. + let doc = s.doc().unwrap(); + let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap(); + assert_eq!(back.node(t).unwrap().board, Some(BoardItem::Table)); +} + +#[test] +fn table_edits_add_and_remove_rows_and_columns_keeping_text() { + let mut s = board(); + let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [["a", "b"], ["c", "d"]]})).unwrap(); + let t = id(&r, "id"); + let edit = |s: &mut Session, p: Value| s.execute("board.tableEdit", &p); + let r = edit(&mut s, json!({"id": t, "action": "addRow", "index": 1})).unwrap(); + assert_eq!(texts(&s, &r), [["a", "b"], ["", ""], ["c", "d"]]); + let r = edit(&mut s, json!({"action": "addColumn", "index": 0})).unwrap(); + assert_eq!(texts(&s, &r), [["", "a", "b"], ["", "", ""], ["", "c", "d"]]); + assert_eq!(node(&s, t).width, 480.0, "a new column as wide as the others"); + assert_eq!(lines(&s, t).len(), 2); + let r = edit(&mut s, json!({"action": "removeRow", "index": 0})).unwrap(); + assert_eq!(texts(&s, &r), [["", "", ""], ["", "c", "d"]]); + let first = node(&s, node(&s, t).children[0]); + assert_eq!(first.fills.len(), 1, "the new first row is the header"); + let r = edit(&mut s, json!({"action": "removeColumn"})).unwrap(); + assert_eq!(texts(&s, &r), [["", ""], ["", "c"]]); + assert_eq!(node(&s, t).width, 320.0); + // From a cell inside it too. + let cell_text = NodeId(r["cells"][1][1].as_u64().unwrap()); + let r = edit(&mut s, json!({"id": cell_text, "action": "addRow"})).unwrap(); + assert_eq!(r["rows"], 3); + // Undo, one step each. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(node(&s, t).width, 480.0); + // Refusals: the wrong action or index, the last row or column, beyond the caps, not a table. + for p in [ + json!({"id": t, "action": "explode"}), + json!({"id": t, "action": "addRow", "index": 9}), + json!({"id": t, "action": "removeColumn", "index": 3}), + json!({"id": t, "action": "addRow", "index": -1}), + json!({"id": t}), + ] { + assert!(edit(&mut s, p.clone()).is_err(), "{p}"); + } + let one = id(&s.execute("board.table", &json!({"rows": 1, "columns": 1})).unwrap(), "id"); + assert!(edit(&mut s, json!({"id": one, "action": "removeRow"})).is_err()); + assert!(edit(&mut s, json!({"id": one, "action": "removeColumn"})).is_err()); + let full = id(&s.execute("board.table", &json!({"rows": 50, "columns": 20})).unwrap(), "id"); + assert!(edit(&mut s, json!({"id": full, "action": "addRow"})).is_err()); + assert!(edit(&mut s, json!({"id": full, "action": "addColumn"})).is_err()); + let sticky = id(&s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(), "id"); + assert!(edit(&mut s, json!({"id": sticky, "action": "addRow"})).is_err()); +} + +#[test] +fn a_double_click_types_in_the_cell_and_its_row_grows_with_the_text() { + let mut s = board(); + let r = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap(); + let t = id(&r, "id"); + let h0 = node(&s, t).height; + s.execute("select.none", &json!({})).unwrap(); + // The middle cell (columns 160 wide, rows about 40 tall). + let row_h = h0 / 3.0; + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 240.0, row_h * 1.5, 1.0)).unwrap(); + let te = s.ds().unwrap().text_edit.clone().expect("typing in the cell"); + assert_eq!(te.id.0, r["cells"][1][1].as_u64().unwrap()); + crate::textedit::insert( + &mut s, + "A longer note +over +several lines", + ) + .unwrap(); + crate::textedit::end(&mut s).unwrap(); + let h1 = node(&s, t).height; + assert!(h1 > h0 + 10.0, "the row grew: {h0} → {h1}"); + assert!(lines(&s, t).iter().all(|(_, h)| (*h - h1).abs() < 1e-6), "column lines follow: {:?} vs {h1}", lines(&s, t)); + // A plain click picks the table whole. + s.execute("select.none", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 240.0, 10.0, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, 240.0, 10.0, 1.0)).unwrap(); + assert_eq!(s.selection(), [t]); +} + +#[test] +fn tables_and_templates_sync_in_live_sessions() { + let s = board(); + let mut room = Room::new(s.doc().unwrap().clone(), 2, 11); + let t = id(&room.s(0).execute("board.table", &json!({"x": 0, "y": 0})).unwrap(), "id"); + room.s(1).execute("board.template", &json!({"name": "flowChart", "x": 0, "y": 600})).unwrap(); + room.settle(); + room.check(); + room.s(1).execute("board.tableEdit", &json!({"id": t, "action": "addColumn"})).unwrap(); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(t).unwrap().width, 640.0); + assert_eq!(doc.nodes().filter(|n| matches!(n.board, Some(BoardItem::Connector(_)))).count(), 6); +} + +#[test] +fn template_and_table_commands_survive_junk() { + let junk = [ + json!({}), + json!(null), + json!([1]), + json!({"name": 7, "x": "NaN", "y": 1e308, "rows": "x", "columns": -1, "cells": 5}), + json!({"name": "kanban", "x": -1e308, "y": 1e308, "rows": 1e308, "columns": 0.5, "cells": [[1, 2]]}), + json!({"name": "\u{0}", "cells": [["a"], "b"], "rows": 50, "columns": 20}), + json!({"id": "x", "action": 5, "index": 1e308}), + json!({"id": -1, "action": "addRow", "index": -1}), + json!({"id": 99_999, "action": "removeColumn", "index": "NaN"}), + json!({"id": 2, "action": "addColumn", "index": 0}), + ]; + for cmd in ["board.template", "board.templates", "board.table", "board.tableEdit"] { + for j in &junk { + let mut s = board(); + let t = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap()["id"].clone(); + let _ = s.execute("board.template", &json!({"name": "brainstorm"})); + let mut j = j.clone(); + if j.get("action").is_some_and(|a| a == "addColumn") { + j["id"] = t; + } + if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, &j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} diff --git a/crates/engine/src/board_templates.rs b/crates/engine/src/board_templates.rs new file mode 100644 index 00000000..2d86ac0b --- /dev/null +++ b/crates/engine/src/board_templates.rs @@ -0,0 +1,261 @@ +//! Board templates: ready-made layouts (a retrospective, a brainstorm, a weekly planner, a flow +//! chart, a kanban board) placed on a board as ordinary board objects in one undo step. They are +//! built in code by running the board commands, so everything in them edits like anything else on +//! the board; their wording is FigCraft's own. + +use std::sync::Arc; + +use figcraft_doc::{Document, NodeId}; +use serde_json::{Value, json}; + +use crate::board::require; +use crate::cmd::CommandSpec; +use crate::params::{id_of, num, string}; +use crate::{EngineError, Result, Session, bad, spec}; + +/// A board template. +pub struct BoardTemplate { + pub id: &'static str, + pub name: &'static str, + /// One line about what is in it. + pub description: &'static str, + /// Its width and height on the board. + pub size: (f64, f64), + make: fn(&mut Builder) -> Result<()>, +} + +pub static TEMPLATES: &[BoardTemplate] = &[ + BoardTemplate { + id: "retrospective", + name: "Retrospective", + description: "Three sections to look back on a stretch of work: what went well, what to improve, what to do next", + size: (1280.0, 592.0), + make: retrospective, + }, + BoardTemplate { + id: "brainstorm", + name: "Brainstorm", + description: "A question in the middle and four clusters of ideas around it", + size: (1344.0, 760.0), + make: brainstorm, + }, + BoardTemplate { + id: "weeklyPlanner", + name: "Weekly planner", + description: "A column for each working day with a note to start from", + size: (1380.0, 632.0), + make: weekly_planner, + }, + BoardTemplate { + id: "flowChart", + name: "Flow chart", + description: "Steps, a decision and its two outcomes joined by connectors", + size: (1220.0, 360.0), + make: flow_chart, + }, + BoardTemplate { + id: "kanban", + name: "Kanban board", + description: "To do, Doing and Done columns with cards to move along", + size: (900.0, 812.0), + make: kanban, + }, +]; + +pub fn specs() -> Vec { + vec![ + spec!( + "board.template", + "Board template", + "{name (a template id from board.templates), x?, y? (its top-left on the page; default centred in the view)} → {name, ids} its sections, stickies, shapes, connectors and text, selected", + true, + template_cmd + ), + spec!("board.templates", "List board templates", "{} → [{id, name, description, width, height}]", false, |_, _| { + Ok(Value::Array( + TEMPLATES + .iter() + .map(|t| json!({"id": t.id, "name": t.name, "description": t.description, "width": t.size.0, "height": t.size.1})) + .collect(), + )) + }), + ] +} + +/// The template called `name` (its id, or its name in any case). +pub fn find(name: &str) -> Option<&'static BoardTemplate> { + let name = name.trim(); + TEMPLATES.iter().find(|t| t.id.eq_ignore_ascii_case(name) || t.name.eq_ignore_ascii_case(name)) +} + +fn template_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.template"; + require(s, cmd)?; + let names = || TEMPLATES.iter().map(|t| t.id).collect::>().join(", "); + let name = string(p, "name").ok_or_else(|| bad(cmd, format!("`name` is one of {}", names())))?; + let t = find(&name).ok_or_else(|| bad(cmd, format!("no template `{name}`: one of {}", names())))?; + let view = &s.ds()?.view; + let at = (num(p, "x").unwrap_or(view.cx - t.size.0 / 2.0), num(p, "y").unwrap_or(view.cy - t.size.1 / 2.0)); + if !(at.0.is_finite() && at.1.is_finite()) { + return Err(bad(cmd, "`x` and `y` are finite numbers")); + } + let page = s.page()?; + let mut b = Builder { s, page, at, made: Vec::new() }; + (t.make)(&mut b)?; + let made = b.made; + s.set_selection(made.clone()); + Ok(json!({"name": t.id, "ids": made})) +} + +/// A board with template `id` at the page's origin and nothing else (the picker's previews). +pub fn preview(id: &str) -> Result { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({"name": id}))?; + s.execute("board.template", &json!({"name": id, "x": 0, "y": 0}))?; + let ds = s.docs.pop().ok_or(EngineError::NoDocument)?; + Ok(Arc::try_unwrap(ds.doc).unwrap_or_else(|d| (*d).clone())) +} + +/// Places a template's objects: positions are from its top-left (`at` on the page), or inside the +/// section they go in. +struct Builder<'a> { + s: &'a mut Session, + page: NodeId, + at: (f64, f64), + /// What it placed on the page. + made: Vec, +} + +impl Builder<'_> { + /// Run board command `cmd` (in this command's undo step); the layer it made. + fn run(&mut self, cmd: &str, p: Value) -> Result { + let spec = crate::cmd::find_command(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?; + let v = (spec.run)(self.s, &p)?; + v.get("id").and_then(id_of).ok_or_else(|| EngineError::Other(format!("`{cmd}` made no layer"))) + } + + /// A layer on the page at (`x`, `y`) from the template's corner. + fn top(&mut self, cmd: &str, x: f64, y: f64, p: Value) -> Result { + let id = self.run(cmd, placed(p, self.page, (self.at.0 + x, self.at.1 + y)))?; + self.made.push(id); + Ok(id) + } + + fn title(&mut self, text: &str) -> Result { + self.top("node.create", 0.0, 0.0, json!({"type": "text", "text": text, "props": {"fontSize": 32, "fills": ["#1E1E1E"]}})) + } + + fn section(&mut self, name: &str, (x, y, w, h): (f64, f64, f64, f64), fill: &str) -> Result { + self.top("node.create", x, y, json!({"type": "section", "name": name, "width": w, "height": h, "props": {"fills": [fill]}})) + } + + /// A sticky in `section` (or on the page) at (`x`, `y`) in it. + fn sticky(&mut self, section: Option, (x, y): (f64, f64), text: &str, color: &str) -> Result { + let p = json!({"text": text, "color": color, "author": false}); + match section { + Some(sec) => self.run("board.sticky", placed(p, sec, (x, y))), + None => self.top("board.sticky", x, y, p), + } + } + + fn shape(&mut self, shape: &str, (x, y, w, h): (f64, f64, f64, f64), text: &str, color: &str) -> Result { + self.top("board.shape", x, y, json!({"shape": shape, "width": w, "height": h, "text": text, "color": color})) + } + + fn connector(&mut self, from: NodeId, to: NodeId, extra: Value) -> Result { + let id = self.run("board.connector", with(extra, [("from", json!(from)), ("to", json!(to))]))?; + self.made.push(id); + Ok(id) + } +} + +/// Params `p` with `entries` added (`p` an object). +fn with(mut p: Value, entries: [(&str, Value); N]) -> Value { + if let Some(o) = p.as_object_mut() { + o.extend(entries.map(|(k, v)| (k.to_string(), v))); + } + p +} + +/// Params `p` placing a layer in `parent` at `at`. +fn placed(p: Value, parent: NodeId, at: (f64, f64)) -> Value { + with(p, [("parent", json!(parent)), ("x", json!(at.0)), ("y", json!(at.1))]) +} + +fn retrospective(b: &mut Builder) -> Result<()> { + b.title("Retrospective")?; + let parts = [ + ("Went well", "#EAF6E1", "green", ["Something that helped us", "A win worth repeating"]), + ("To improve", "#FCE8EF", "pink", ["Something that slowed us down", "A question we still have"]), + ("Next steps", "#E6F0FC", "blue", ["One change to try next", "Who looks after it?"]), + ]; + for (i, (name, fill, color, notes)) in parts.into_iter().enumerate() { + let sec = b.section(name, (i as f64 * 440.0, 72.0, 400.0, 520.0), fill)?; + for (j, note) in notes.into_iter().enumerate() { + b.sticky(Some(sec), (100.0, 60.0 + j as f64 * 230.0), note, color)?; + } + } + Ok(()) +} + +fn brainstorm(b: &mut Builder) -> Result<()> { + let topic = b.shape("ellipse", (532.0, 300.0, 280.0, 160.0), "What are we trying to solve?", "violet")?; + let clusters = [((0.0, 0.0), "yellow", 1), ((920.0, 0.0), "orange", 0), ((0.0, 560.0), "green", 1), ((920.0, 560.0), "blue", 0)]; + for ((x, y), color, near) in clusters { + let notes = [b.sticky(None, (x, y), "An idea", color)?, b.sticky(None, (x + 224.0, y), "Build on it", color)?]; + if let Some(n) = notes.get(near) { + b.connector(topic, *n, json!({"style": "straight", "endCap": "none"}))?; + } + } + Ok(()) +} + +fn weekly_planner(b: &mut Builder) -> Result<()> { + b.title("This week")?; + let days = [ + ("Monday", "Plan the week"), + ("Tuesday", "Focus for the day"), + ("Wednesday", "Focus for the day"), + ("Thursday", "Focus for the day"), + ("Friday", "Wrap up and share"), + ]; + for (i, (day, note)) in days.into_iter().enumerate() { + let sec = b.section(day, (i as f64 * 280.0, 72.0, 260.0, 560.0), "#FFFFFF")?; + b.sticky(Some(sec), (30.0, 60.0), note, if i % 2 == 0 { "yellow" } else { "orange" })?; + } + Ok(()) +} + +fn flow_chart(b: &mut Builder) -> Result<()> { + let start = b.shape("ellipse", (0.0, 30.0, 160.0, 100.0), "Start", "green")?; + let step = b.shape("rectangle", (240.0, 20.0, 180.0, 120.0), "Do the first step", "blue")?; + let check = b.shape("diamond", (500.0, 0.0, 200.0, 160.0), "Did it work?", "yellow")?; + let carry = b.shape("roundedRectangle", (800.0, 20.0, 180.0, 120.0), "Carry on", "blue")?; + let done = b.shape("ellipse", (1060.0, 30.0, 160.0, 100.0), "Done", "green")?; + let other = b.shape("roundedRectangle", (510.0, 240.0, 180.0, 120.0), "Try another way", "orange")?; + let elbow = json!({"style": "elbow"}); + b.connector(start, step, elbow.clone())?; + b.connector(step, check, elbow.clone())?; + b.connector(check, carry, json!({"style": "elbow", "label": "Yes"}))?; + b.connector(carry, done, elbow)?; + b.connector(check, other, json!({"style": "elbow", "fromSide": "bottom", "toSide": "top", "label": "No"}))?; + // Back round to the step: out of the side, into the step from below. + b.connector(other, step, json!({"style": "elbow", "fromSide": "left", "toSide": "bottom"}))?; + Ok(()) +} + +fn kanban(b: &mut Builder) -> Result<()> { + b.title("Team board")?; + let columns: [(&str, &str, &[&str]); 3] = [ + ("To do", "yellow", &["Write the brief", "Sketch the first screens", "Plan a test session"]), + ("Doing", "blue", &["Review the copy"]), + ("Done", "green", &["Set up the board"]), + ]; + for (i, (name, color, cards)) in columns.into_iter().enumerate() { + let sec = b.section(name, (i as f64 * 310.0, 72.0, 280.0, 740.0), "#FFFFFF")?; + for (j, card) in cards.iter().enumerate() { + b.sticky(Some(sec), (40.0, 60.0 + j as f64 * 220.0), card, color)?; + } + } + Ok(()) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 20dd488c..a247ed52 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -115,6 +115,8 @@ fn registry() -> &'static Registry { list.extend(crate::textfit::specs()); list.extend(crate::board::specs()); list.extend(facilitate::specs()); + list.extend(crate::board_table::specs()); + list.extend(crate::board_templates::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5d2ee69d..44502f4f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,6 +15,8 @@ pub mod arc_handles; pub mod background; pub mod batch; pub mod board; +pub mod board_table; +pub mod board_templates; pub mod booleans; pub mod branches; pub mod catalog; @@ -1640,6 +1642,8 @@ mod binding_lists_tests; #[cfg(test)] mod board_connector_tests; #[cfg(test)] +mod board_tables_tests; +#[cfg(test)] mod board_tests; #[cfg(test)] mod board_tools_tests; diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 03cecc4c..09ff2e44 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -45,6 +45,7 @@ status = "draft — needs native review" "Add another condition" = "আরেকটি শর্ত যোগ করুন" "Add auto layout" = "অটো লেআউট যোগ করুন" "Add background behind overlay" = "ওভারলে-এর পেছনে ব্যাকগ্রাউন্ড যোগ করুন" +"Add column" = "কলাম যোগ করুন" "Add content to this slot" = "এই স্লটে কন্টেন্ট যোগ করুন" "Add dev resource" = "ডেভ রিসোর্স যোগ করুন" "Add effect" = "ইফেক্ট যোগ করুন" @@ -64,6 +65,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "একটি ভেরিয়েবল JSON থেকে সংগ্রহ যোগ বা আপডেট করুন" "Add properties to make instances configurable" = "ইনস্ট্যান্সগুলোকে কনফিগারযোগ্য করতে প্রপার্টি যোগ করুন" "Add reaction" = "রিঅ্যাকশন যোগ করুন" +"Add row" = "সারি যোগ করুন" "Add starting point" = "শুরু করার বিন্দু যোগ করুন" "Add stroke" = "স্ট্রোক যোগ করুন" "Add variant" = "ভ্যারিয়েন্ট যোগ করুন" @@ -183,6 +185,7 @@ status = "draft — needs native review" "Bottom" = "নিচে" "Bottom left radius" = "নিচের বাম ব্যাসার্ধ" "Bottom right radius" = "নিচের ডান ব্যাসার্ধ" +"Brainstorm" = "ব্রেইনস্টর্ম" "Branch" = "ব্রাঞ্চ" "Branch archived" = "ব্রাঞ্চ আর্কাইভ করা হয়েছে" "Branch merged" = "ব্রাঞ্চ মার্জ করা হয়েছে" @@ -542,6 +545,7 @@ status = "draft — needs native review" "Flip text to the other side" = "টেক্সট অন্য পাশে উল্টান" "Flip vertical" = "উল্লম্বভাবে উল্টান" "Flip width profile" = "প্রস্থ প্রোফাইল উল্টান" +"Flow chart" = "ফ্লো চার্ট" "Flow starting point" = "ফ্লো শুরুর বিন্দু" "Flow starting points" = "ফ্লো শুরুর বিন্দুসমূহ" "Flows" = "ফ্লোসমূহ" @@ -696,6 +700,7 @@ status = "draft — needs native review" "Jun" = "জুন" "Just now" = "এইমাত্র" "Justify" = "উভয়মুখী সারিবদ্ধকরণ (Justify)" +"Kanban board" = "কানবান বোর্ড" "Keep a measurement on the canvas for developers" = "ডেভেলপারদের জন্য ক্যানভাসে একটি মাপ রাখুন" "Keep appearance" = "চেহারা অপরিবর্তিত রাখুন" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "চেহারা অপরিবর্তিত রাখলে প্রতিটি রঙের মান {profile}-এ রূপান্তরিত হয় যাতে রঙগুলো একই দেখায়। মান অপরিবর্তিত রাখলে সংখ্যাগুলো যেমন আছে তেমনই থাকে, ফলে রঙ পরিবর্তিত হয়।" @@ -1097,6 +1102,7 @@ status = "draft — needs native review" "Remove all" = "সব সরান" "Remove annotation" = "অ্যানোটেশন সরান" "Remove auto layout" = "অটো লেআউট সরান" +"Remove column" = "কলাম সরান" "Remove dev resource" = "ডেভ রিসোর্স সরান" "Remove export setting" = "এক্সপোর্ট সেটিংস সরান" "Remove from recents" = "সাম্প্রতিক থেকে সরান" @@ -1110,6 +1116,7 @@ status = "draft — needs native review" "Remove min and max height" = "সর্বনিম্ন এবং সর্বোচ্চ উচ্চতা সরান" "Remove min and max width" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" "Remove regions" = "অঞ্চলগুলো সরান" +"Remove row" = "সারি সরান" "Remove slot" = "স্লট সরান" "Remove starting point" = "শুরু করার বিন্দু সরান" "Remove stop" = "স্টপ সরান" @@ -1162,6 +1169,7 @@ status = "draft — needs native review" "Restore main component" = "প্রধান কম্পোনেন্ট পুনরুদ্ধার করুন" "Restore this version" = "এই সংস্করণ পুনরুদ্ধার করুন" "Restored “{name}”" = "“{name}” পুনরুদ্ধার করা হয়েছে" +"Retrospective" = "রেট্রোস্পেক্টিভ" "Results" = "ফলাফল" "Resume" = "আবার চালু করুন" "Reverse the order" = "ক্রম উল্টে দিন" @@ -1548,6 +1556,7 @@ status = "draft — needs native review" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL কপি করা হয়েছে" "Wedge" = "ওয়েজ" +"Weekly planner" = "সাপ্তাহিক পরিকল্পনা" "Weight" = "ওজন" "What you try here changes this preview and the code below, not the file" = "আপনি এখানে যা চেষ্টা করেন তা ফাইলটিকে নয়, কেবল এই প্রিভিউ এবং নিচের কোডটিকে পরিবর্তন করে" "What changed since it was marked ready for dev" = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কী পরিবর্তিত হয়েছে" @@ -1811,6 +1820,10 @@ status = "draft — needs native review" "board.shape" = "লেখাসহ আকৃতি" "board.stamp" = "স্ট্যাম্প" "board.sticky" = "স্টিকি নোট" +"board.table" = "টেবিল" +"board.tableEdit" = "টেবিল সম্পাদনা" +"board.template" = "বোর্ড টেমপ্লেট" +"board.templates" = "বোর্ড টেমপ্লেটের তালিকা" "board.timer" = "টাইমার" "board.timerState" = "টাইমারের অবস্থা" "board.vote" = "ভোটিং সেশন" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 4d71c66c..50775f76 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -41,6 +41,7 @@ status = "draft — needs native review" "Add another condition" = "Přidat další podmínku" "Add auto layout" = "Přidat automatické rozvržení" "Add background behind overlay" = "Přidat pozadí za překryv" +"Add column" = "Přidat sloupec" "Add content to this slot" = "Přidat obsah do tohoto slotu" "Add dev resource" = "Přidat zdroj pro vývojáře" "Add effect" = "Přidat efekt" @@ -60,6 +61,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Přidat nebo aktualizovat kolekce ze souboru JSON s proměnnými" "Add properties to make instances configurable" = "Přidejte vlastnosti pro nastavení instancí" "Add reaction" = "Přidat reakci" +"Add row" = "Přidat řádek" "Add starting point" = "Přidat počáteční bod" "Add stroke" = "Přidat tah" "Add variant" = "Přidat variantu" @@ -177,6 +179,7 @@ status = "draft — needs native review" "Bottom" = "Dole" "Bottom left radius" = "Poloměr levého dolního rohu" "Bottom right radius" = "Poloměr pravého dolního rohu" +"Brainstorm" = "Brainstorming" "Branch" = "Větev" "Branch archived" = "Větev archivována" "Branch merged" = "Větev sloučena" @@ -528,6 +531,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Převrátit text na druhou stranu" "Flip vertical" = "Převrátit svisle" "Flip width profile" = "Převrátit profil šířky" +"Flow chart" = "Vývojový diagram" "Flow starting point" = "Počáteční bod toku" "Flow starting points" = "Počáteční body toků" "Flows" = "Toky" @@ -681,6 +685,7 @@ status = "draft — needs native review" "Jun" = "června" "Just now" = "Právě teď" "Justify" = "Do bloku" +"Kanban board" = "Kanbanová tabule" "Keep a measurement on the canvas for developers" = "Ponechat měření na plátně pro vývojáře" "Keep appearance" = "Zachovat vzhled" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachovat vzhled převede všechny hodnoty barev do profilu {profile}, aby barvy vypadaly stejně. Zachovat hodnoty ponechá čísla beze změny, takže se barvy posunou." @@ -1076,6 +1081,7 @@ status = "draft — needs native review" "Remove all" = "Odebrat vše" "Remove annotation" = "Odebrat anotaci" "Remove auto layout" = "Odebrat automatické rozvržení" +"Remove column" = "Odebrat sloupec" "Remove dev resource" = "Odebrat zdroj pro vývojáře" "Remove export setting" = "Odebrat nastavení exportu" "Remove from recents" = "Odebrat z nedávných" @@ -1089,6 +1095,7 @@ status = "draft — needs native review" "Remove min and max height" = "Odebrat minimální a maximální výšku" "Remove min and max width" = "Odebrat minimální a maximální šířku" "Remove regions" = "Odebrat oblasti" +"Remove row" = "Odebrat řádek" "Remove slot" = "Odebrat slot" "Remove starting point" = "Odebrat počáteční bod" "Remove stop" = "Odebrat zarážku" @@ -1140,6 +1147,7 @@ status = "draft — needs native review" "Restore main component" = "Obnovit hlavní komponentu" "Restore this version" = "Obnovit tuto verzi" "Restored “{name}”" = "Obnoveno: „{name}“" +"Retrospective" = "Retrospektiva" "Results" = "Výsledky" "Resume" = "Pokračovat" "Reverse the order" = "Obrátit pořadí" @@ -1515,6 +1523,7 @@ status = "draft — needs native review" "WebSocket URL" = "URL WebSocketu" "WebSocket URL copied" = "URL WebSocketu zkopírována" "Wedge" = "Klín" +"Weekly planner" = "Týdenní plán" "Weight" = "Tloušťka" "What you try here changes this preview and the code below, not the file" = "Zdejší zkoušení mění tento náhled a kód níže, soubor zůstává beze změny" "What changed since it was marked ready for dev" = "Co se změnilo od označení jako připravené pro vývoj" @@ -1799,6 +1808,10 @@ status = "draft — needs native review" "board.shape" = "Tvar s textem" "board.stamp" = "Razítko" "board.sticky" = "Lepicí poznámka" +"board.table" = "Tabulka" +"board.tableEdit" = "Upravit tabulku" +"board.template" = "Šablona tabule" +"board.templates" = "Seznam šablon tabule" "board.timer" = "Časovač" "board.timerState" = "Stav časovače" "board.vote" = "Hlasování" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 4a527081..dff589cd 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -42,6 +42,7 @@ status = "draft — needs native review" "Add another condition" = "Weitere Bedingung hinzufügen" "Add auto layout" = "Auto-Layout hinzufügen" "Add background behind overlay" = "Hintergrund hinter dem Overlay hinzufügen" +"Add column" = "Spalte hinzufügen" "Add content to this slot" = "Inhalt zu diesem Slot hinzufügen" "Add dev resource" = "Entwicklerressource hinzufügen" "Add effect" = "Effekt hinzufügen" @@ -61,6 +62,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Sammlungen aus einer Variablen-JSON hinzufügen oder aktualisieren" "Add properties to make instances configurable" = "Fügen Sie Eigenschaften hinzu, um Instanzen konfigurierbar zu machen" "Add reaction" = "Reaktion hinzufügen" +"Add row" = "Zeile hinzufügen" "Add starting point" = "Startpunkt hinzufügen" "Add stroke" = "Kontur hinzufügen" "Add variant" = "Variante hinzufügen" @@ -174,6 +176,7 @@ status = "draft — needs native review" "Bottom" = "Unten" "Bottom left radius" = "Radius unten links" "Bottom right radius" = "Radius unten rechts" +"Brainstorm" = "Brainstorming" "Branch" = "Branch" "Branch archived" = "Branch archiviert" "Branch merged" = "Branch zusammengeführt" @@ -521,6 +524,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Text auf die andere Seite setzen" "Flip vertical" = "Vertikal spiegeln" "Flip width profile" = "Breitenprofil umkehren" +"Flow chart" = "Flussdiagramm" "Flow starting point" = "Startpunkt des Ablaufs" "Flow starting points" = "Startpunkte von Abläufen" "Flows" = "Abläufe" @@ -665,6 +669,7 @@ status = "draft — needs native review" "Join|stroke" = "Ecke" "Just now" = "Gerade eben" "Justify" = "Blocksatz" +"Kanban board" = "Kanban-Board" "Keep a measurement on the canvas for developers" = "Eine Messung für Entwickler auf der Arbeitsfläche behalten" "Keep appearance" = "Aussehen beibehalten" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„Aussehen beibehalten“ wandelt jeden Farbwert in {profile} um, damit die Farben gleich aussehen. „Werte beibehalten“ lässt die Zahlen unverändert, sodass sich die Farben verschieben." @@ -1045,6 +1050,7 @@ status = "draft — needs native review" "Remove all" = "Alle entfernen" "Remove annotation" = "Anmerkung entfernen" "Remove auto layout" = "Auto-Layout entfernen" +"Remove column" = "Spalte entfernen" "Remove dev resource" = "Entwicklerressource entfernen" "Remove export setting" = "Exporteinstellung entfernen" "Remove from server" = "Vom Server entfernen" @@ -1057,6 +1063,7 @@ status = "draft — needs native review" "Remove min and max height" = "Min. und max. Höhe entfernen" "Remove min and max width" = "Min. und max. Breite entfernen" "Remove regions" = "Bereiche entfernen" +"Remove row" = "Zeile entfernen" "Remove slot" = "Slot entfernen" "Remove starting point" = "Startpunkt entfernen" "Remove stop" = "Farbunterbrechung entfernen" @@ -1110,6 +1117,7 @@ status = "draft — needs native review" "Restore main component" = "Hauptkomponente wiederherstellen" "Restore this version" = "Diese Version wiederherstellen" "Restored “{name}”" = "„{name}“ wiederhergestellt" +"Retrospective" = "Retrospektive" "Results" = "Ergebnisse" "Resume" = "Fortsetzen" "Reverse the order" = "Reihenfolge umkehren" @@ -1329,6 +1337,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "Teilen Sie dem Betreiber des Servers mit, was damit nicht stimmt" "Temperature" = "Temperatur" "Template" = "Vorlage" +"Templates" = "Vorlagen" "Terms" = "Bedingungen" "Text" = "Text" "Text align" = "Textausrichtung" @@ -1475,6 +1484,7 @@ status = "draft — needs native review" "WebSocket URL" = "WebSocket-URL" "WebSocket URL copied" = "WebSocket-URL kopiert" "Wedge" = "Keil" +"Weekly planner" = "Wochenplaner" "Weight" = "Stärke" "What changed since a saved version" = "Was sich seit einer gespeicherten Version geändert hat" "What changed since it was marked ready for dev" = "Was sich seit der Markierung als bereit für die Entwicklung geändert hat" @@ -1755,6 +1765,10 @@ status = "draft — needs native review" "board.shape" = "Form mit Text" "board.stamp" = "Stempel" "board.sticky" = "Haftnotiz" +"board.table" = "Tabelle" +"board.tableEdit" = "Tabelle bearbeiten" +"board.template" = "Whiteboard-Vorlage" +"board.templates" = "Whiteboard-Vorlagen auflisten" "board.timer" = "Timer" "board.timerState" = "Timer-Status" "board.vote" = "Abstimmung" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 588a4599..9be2eb21 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -44,6 +44,7 @@ name = "English" "Add another condition" = "Add another condition" "Add auto layout" = "Add auto layout" "Add background behind overlay" = "Add background behind overlay" +"Add column" = "Add column" "Add content to this slot" = "Add content to this slot" "Add dev resource" = "Add dev resource" "Add effect" = "Add effect" @@ -63,6 +64,7 @@ name = "English" "Add or update collections from a variables JSON" = "Add or update collections from a variables JSON" "Add properties to make instances configurable" = "Add properties to make instances configurable" "Add reaction" = "Add reaction" +"Add row" = "Add row" "Add starting point" = "Add starting point" "Add stroke" = "Add stroke" "Add variant" = "Add variant" @@ -183,6 +185,7 @@ name = "English" "Bottom left radius" = "Bottom left radius" "Bottom padding" = "Bottom padding" "Bottom right radius" = "Bottom right radius" +"Brainstorm" = "Brainstorm" "Branch" = "Branch" "Branch archived" = "Branch archived" "Branch merged" = "Branch merged" @@ -555,6 +558,7 @@ name = "English" "Flip text to the other side" = "Flip text to the other side" "Flip vertical" = "Flip vertical" "Flip width profile" = "Flip width profile" +"Flow chart" = "Flow chart" "Flow starting point" = "Flow starting point" "Flow starting points" = "Flow starting points" "Flows" = "Flows" @@ -712,6 +716,7 @@ name = "English" "Jun" = "Jun" "Just now" = "Just now" "Justify" = "Justify" +"Kanban board" = "Kanban board" "Keep a measurement on the canvas for developers" = "Keep a measurement on the canvas for developers" "Keep appearance" = "Keep appearance" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." @@ -1122,6 +1127,7 @@ name = "English" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" "Remove auto layout" = "Remove auto layout" +"Remove column" = "Remove column" "Remove dev resource" = "Remove dev resource" "Remove export setting" = "Remove export setting" "Remove from recents" = "Remove from recents" @@ -1135,6 +1141,7 @@ name = "English" "Remove min and max height" = "Remove min and max height" "Remove min and max width" = "Remove min and max width" "Remove regions" = "Remove regions" +"Remove row" = "Remove row" "Remove slot" = "Remove slot" "Remove starting point" = "Remove starting point" "Remove stop" = "Remove stop" @@ -1189,6 +1196,7 @@ name = "English" "Restore main component" = "Restore main component" "Restore this version" = "Restore this version" "Restored “{name}”" = "Restored “{name}”" +"Retrospective" = "Retrospective" "Results" = "Results" "Resume" = "Resume" "Reverse the order" = "Reverse the order" @@ -1589,6 +1597,7 @@ name = "English" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL copied" "Wedge" = "Wedge" +"Weekly planner" = "Weekly planner" "Weight" = "Weight" "What changed since a saved version" = "What changed since a saved version" "What you try here changes this preview and the code below, not the file" = "What you try here changes this preview and the code below, not the file" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 7683e25e..5b9668da 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -25,6 +25,7 @@ status = "draft — needs native review" "Add another condition" = "Agregar otra condición" "Add auto layout" = "Agregar diseño automático" "Add background behind overlay" = "Agregar fondo tras la superposición" +"Add column" = "Agregar columna" "Add content to this slot" = "Agregar contenido a esta ranura" "Add dev resource" = "Agregar recurso de desarrollo" "Add effect" = "Agregar efecto" @@ -44,6 +45,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Agregar o actualizar colecciones desde un JSON de variables" "Add properties to make instances configurable" = "Agrega propiedades para que las instancias sean configurables" "Add reaction" = "Agregar reacción" +"Add row" = "Agregar fila" "Add starting point" = "Agregar punto de inicio" "Add stroke" = "Agregar trazo" "Add variant" = "Agregar variante" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index efdc05f2..ef34872e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,8 +8,10 @@ name = "Español" status = "draft — needs native review" [ui] -"Add a minute" = "Añadir un minuto" +"Add column" = "Añadir columna" "Add measurement" = "Añadir medición" +"Add row" = "Añadir fila" +"Add a minute" = "Añadir un minuto" "All objects" = "Todos los objetos" "Along the bottom" = "A lo largo del borde inferior" "Along the left" = "A lo largo del borde izquierdo" @@ -17,6 +19,7 @@ status = "draft — needs native review" "Along the top" = "A lo largo del borde superior" "Arrow at the end" = "Flecha al final" "Between the selected layers" = "Entre las capas seleccionadas" +"Brainstorm" = "Lluvia de ideas" "Check" = "Visto" "Chevron" = "Cheurón" "Clear results" = "Borrar resultados" @@ -550,6 +553,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Pasar el texto al otro lado" "Flip vertical" = "Voltear verticalmente" "Flip width profile" = "Invertir perfil de ancho" +"Flow chart" = "Diagrama de flujo" "Flow starting point" = "Punto de inicio del flujo" "Flow starting points" = "Puntos de inicio de flujo" "Flows" = "Flujos" @@ -707,6 +711,7 @@ status = "draft — needs native review" "Jun" = "jun" "Just now" = "Justo ahora" "Justify" = "Justificar" +"Kanban board" = "Tablero kanban" "Keep a measurement on the canvas for developers" = "Mantén una medición en el lienzo para desarrolladores" "Keep appearance" = "Mantener apariencia" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Mantener apariencia convierte cada valor de color a {profile} para que los colores se vean igual. Mantener valores deja los números como están, así que los colores cambian." @@ -1116,6 +1121,7 @@ status = "draft — needs native review" "Remove all" = "Quitar todo" "Remove annotation" = "Quitar anotación" "Remove auto layout" = "Quitar diseño automático" +"Remove column" = "Quitar columna" "Remove dev resource" = "Quitar recurso de desarrollo" "Remove export setting" = "Quitar ajuste de exportación" "Remove from recents" = "Quitar de recientes" @@ -1129,6 +1135,7 @@ status = "draft — needs native review" "Remove min and max height" = "Quitar alto mín. y máx." "Remove min and max width" = "Quitar ancho mín. y máx." "Remove regions" = "Quitar regiones" +"Remove row" = "Quitar fila" "Remove slot" = "Quitar ranura" "Remove starting point" = "Quitar punto de inicio" "Remove stop" = "Quitar parada" @@ -1183,6 +1190,7 @@ status = "draft — needs native review" "Restore main component" = "Restaurar componente principal" "Restore this version" = "Restaurar esta versión" "Restored “{name}”" = "“{name}” restaurada" +"Retrospective" = "Retrospectiva" "Results" = "Resultados" "Resume" = "Reanudar" "Reverse the order" = "Invertir el orden" @@ -1579,6 +1587,7 @@ status = "draft — needs native review" "WebSocket URL" = "URL de WebSocket" "WebSocket URL copied" = "URL de WebSocket copiada" "Wedge" = "Cuña" +"Weekly planner" = "Planificador semanal" "Weight" = "Grosor" "What changed since a saved version" = "Qué cambió desde una versión guardada" "What you try here changes this preview and the code below, not the file" = "Lo que pruebes aquí cambia esta vista previa y el código de abajo, no el archivo" @@ -1873,6 +1882,10 @@ status = "draft — needs native review" "board.shape" = "Forma con texto" "board.stamp" = "Sello" "board.sticky" = "Nota adhesiva" +"board.table" = "Tabla" +"board.tableEdit" = "Editar tabla" +"board.template" = "Plantilla de pizarra" +"board.templates" = "Listar plantillas de pizarra" "board.timer" = "Temporizador" "board.timerState" = "Estado del temporizador" "board.vote" = "Votación" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index e6fcb424..0bfcd574 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -46,6 +46,7 @@ status = "draft — needs native review" "Add another condition" = "Lisää toinen ehto" "Add auto layout" = "Lisää automaattinen asettelu" "Add background behind overlay" = "Lisää tausta peittokuvan taakse" +"Add column" = "Lisää sarake" "Add content to this slot" = "Lisää sisältöä tähän sisältöpaikkaan" "Add dev resource" = "Lisää kehittäjäresurssi" "Add effect" = "Lisää tehoste" @@ -65,6 +66,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Lisää tai päivitä kokoelmia muuttujien JSON-tiedostosta" "Add properties to make instances configurable" = "Lisää ominaisuuksia, jotta ilmentymiä voi mukauttaa" "Add reaction" = "Lisää reaktio" +"Add row" = "Lisää rivi" "Add starting point" = "Lisää aloituspiste" "Add stroke" = "Lisää reunaviiva" "Add variant" = "Lisää variantti" @@ -185,6 +187,7 @@ status = "draft — needs native review" "Bottom left radius" = "Vasemman alakulman säde" "Bottom right radius" = "Oikean alakulman säde" "Bound to {name}. Click to detach the variable." = "Sidottu muuttujaan {name}. Irrota muuttuja napsauttamalla." +"Brainstorm" = "Ideointi" "Branch" = "Haara" "Branch archived" = "Haara arkistoitu" "Branch merged" = "Haara yhdistetty" @@ -544,6 +547,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Siirrä teksti toiselle puolelle" "Flip vertical" = "Peilaa pystysuunnassa" "Flip width profile" = "Peilaa leveysprofiili" +"Flow chart" = "Vuokaavio" "Flow starting point" = "Kulun aloituspiste" "Flow starting points" = "Kulkujen aloituspisteet" "Flows" = "Kulut" @@ -698,6 +702,7 @@ status = "draft — needs native review" "Jun" = "kesäk." "Just now" = "Juuri nyt" "Justify" = "Tasaa molemmat reunat" +"Kanban board" = "Kanban-taulu" "Keep a measurement on the canvas for developers" = "Säilytä mittaus piirtoalueella kehittäjille" "Keep appearance" = "Säilytä ulkoasu" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Säilytä ulkoasu muuntaa kaikki väriarvot profiiliin {profile}, jotta värit näyttävät samoilta. Säilytä arvot jättää lukuarvot ennalleen, jolloin värit muuttuvat." @@ -1101,6 +1106,7 @@ status = "draft — needs native review" "Remove all" = "Poista kaikki" "Remove annotation" = "Poista huomautus" "Remove auto layout" = "Poista automaattinen asettelu" +"Remove column" = "Poista sarake" "Remove dev resource" = "Poista kehittäjäresurssi" "Remove export setting" = "Poista vientiasetus" "Remove from recents" = "Poista viimeisimmistä" @@ -1114,6 +1120,7 @@ status = "draft — needs native review" "Remove min and max height" = "Poista korkeuden vähimmäis- ja enimmäisrajat" "Remove min and max width" = "Poista leveyden vähimmäis- ja enimmäisrajat" "Remove regions" = "Poista alueet" +"Remove row" = "Poista rivi" "Remove slot" = "Poista sisältöpaikka" "Remove starting point" = "Poista aloituspiste" "Remove stop" = "Poista liukuvärin piste" @@ -1166,6 +1173,7 @@ status = "draft — needs native review" "Restore main component" = "Palauta pääkomponentti" "Restore this version" = "Palauta tämä versio" "Restored “{name}”" = "”{name}” palautettu" +"Retrospective" = "Retrospektiivi" "Results" = "Tulokset" "Resume" = "Jatka" "Reverse the order" = "Käännä järjestys" @@ -1553,6 +1561,7 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket-URL kopioitu" "Websites" = "Verkkosivustot" "Wedge" = "Kiila" +"Weekly planner" = "Viikkosuunnitelma" "Weight" = "Paksuus" "What changed since it was marked ready for dev" = "Muutokset kehitysvalmiiksi merkitsemisen jälkeen" "What is wrong with this file? The server's operator gets your report." = "Mikä tässä tiedostossa on vikana? Palvelimen ylläpitäjä saa ilmoituksesi." @@ -1841,6 +1850,10 @@ status = "draft — needs native review" "board.shape" = "Muoto ja teksti" "board.stamp" = "Leima" "board.sticky" = "Muistilappu" +"board.table" = "Taulukko" +"board.tableEdit" = "Muokkaa taulukkoa" +"board.template" = "Taulun malli" +"board.templates" = "Luettele taulun mallit" "board.timer" = "Ajastin" "board.timerState" = "Ajastimen tila" "board.vote" = "Äänestys" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index bf98f8dc..b7a00ea0 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -45,6 +45,7 @@ status = "draft — needs native review" "Add another condition" = "Ajouter une autre condition" "Add auto layout" = "Ajouter la mise en page auto" "Add background behind overlay" = "Ajouter un fond derrière la superposition" +"Add column" = "Ajouter une colonne" "Add content to this slot" = "Ajouter du contenu à cet emplacement" "Add dev resource" = "Ajouter une ressource de développement" "Add effect" = "Ajouter un effet" @@ -64,6 +65,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Ajouter ou mettre à jour des collections depuis un JSON de variables" "Add properties to make instances configurable" = "Ajoutez des propriétés pour rendre les instances configurables" "Add reaction" = "Ajouter une réaction" +"Add row" = "Ajouter une ligne" "Add starting point" = "Ajouter un point de départ" "Add stroke" = "Ajouter un contour" "Add variant" = "Ajouter une variante" @@ -183,6 +185,7 @@ status = "draft — needs native review" "Bottom left radius" = "Rayon inférieur gauche" "Bottom right radius" = "Rayon inférieur droit" "Bound to {name}. Click to detach the variable." = "Lié à {name}. Cliquez pour détacher la variable." +"Brainstorm" = "Remue-méninges" "Branch" = "Branche" "Branch archived" = "Branche archivée" "Branch merged" = "Branche fusionnée" @@ -543,6 +546,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Passer le texte de l’autre côté" "Flip vertical" = "Retourner verticalement" "Flip width profile" = "Inverser le profil de largeur" +"Flow chart" = "Logigramme" "Flow starting point" = "Point de départ du flux" "Flow starting points" = "Points de départ de flux" "Flows" = "Flux" @@ -698,6 +702,7 @@ status = "draft — needs native review" "Jun" = "juin" "Just now" = "À l’instant" "Justify" = "Justifier" +"Kanban board" = "Tableau kanban" "Keep a measurement on the canvas for developers" = "Garder une mesure sur le canevas pour les développeurs" "Keep appearance" = "Conserver l’apparence" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Conserver l’apparence convertit chaque valeur de couleur en {profile} pour que les couleurs restent identiques. Conserver les valeurs laisse les nombres tels quels, si bien que les couleurs changent." @@ -1101,6 +1106,7 @@ status = "draft — needs native review" "Remove all" = "Tout retirer" "Remove annotation" = "Retirer l’annotation" "Remove auto layout" = "Retirer la mise en page auto" +"Remove column" = "Retirer la colonne" "Remove dev resource" = "Retirer la ressource de développement" "Remove export setting" = "Retirer le réglage d’exportation" "Remove from recents" = "Retirer des récents" @@ -1114,6 +1120,7 @@ status = "draft — needs native review" "Remove min and max height" = "Retirer la hauteur min. et max." "Remove min and max width" = "Retirer la largeur min. et max." "Remove regions" = "Retirer les régions" +"Remove row" = "Retirer la ligne" "Remove slot" = "Retirer l’emplacement" "Remove starting point" = "Retirer le point de départ" "Remove stop" = "Retirer l’arrêt" @@ -1167,6 +1174,7 @@ status = "draft — needs native review" "Restore main component" = "Restaurer le composant principal" "Restore this version" = "Restaurer cette version" "Restored “{name}”" = "« {name} » restaurée" +"Retrospective" = "Rétrospective" "Results" = "Résultats" "Resume" = "Reprendre" "Reverse the order" = "Inverser l’ordre" @@ -1554,6 +1562,7 @@ status = "draft — needs native review" "WebSocket URL copied" = "URL WebSocket copiée" "Websites" = "Sites web" "Wedge" = "Coin" +"Weekly planner" = "Planning de la semaine" "Weight" = "Graisse" "What changed since a saved version" = "Ce qui a changé depuis une version enregistrée" "What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev" @@ -1842,6 +1851,10 @@ status = "draft — needs native review" "board.shape" = "Forme avec texte" "board.stamp" = "Tampon" "board.sticky" = "Note autocollante" +"board.table" = "Tableau" +"board.tableEdit" = "Modifier le tableau" +"board.template" = "Modèle de tableau blanc" +"board.templates" = "Lister les modèles de tableau blanc" "board.timer" = "Minuteur" "board.timerState" = "État du minuteur" "board.vote" = "Session de vote" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index b21fd921..6878bee1 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -45,6 +45,7 @@ status = "draft — needs native review" "Add another condition" = "Tambah kondisi lain" "Add auto layout" = "Tambah auto layout" "Add background behind overlay" = "Tambah latar belakang di balik overlay" +"Add column" = "Tambah kolom" "Add content to this slot" = "Tambah konten ke slot ini" "Add dev resource" = "Tambah sumber daya dev" "Add effect" = "Tambah efek" @@ -64,6 +65,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Tambah atau perbarui koleksi dari JSON variabel" "Add properties to make instances configurable" = "Tambahkan properti agar instance dapat dikonfigurasi" "Add reaction" = "Tambah reaksi" +"Add row" = "Tambah baris" "Add starting point" = "Tambah titik awal" "Add stroke" = "Tambah stroke" "Add variant" = "Tambah variant" @@ -181,6 +183,7 @@ status = "draft — needs native review" "Bottom" = "Bawah" "Bottom left radius" = "Radius kiri bawah" "Bottom right radius" = "Radius kanan bawah" +"Brainstorm" = "Curah gagasan" "Branch" = "Branch" "Branch archived" = "Branch diarsipkan" "Branch merged" = "Branch di-merge" @@ -532,6 +535,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Balik teks ke sisi lain" "Flip vertical" = "Balik vertikal" "Flip width profile" = "Balik profil lebar" +"Flow chart" = "Diagram alir" "Flow starting point" = "Titik awal alur" "Flow starting points" = "Titik awal alur" "Flows" = "Alur" @@ -685,6 +689,7 @@ status = "draft — needs native review" "Jun" = "Jun" "Just now" = "Baru saja" "Justify" = "Rata kiri-kanan" +"Kanban board" = "Papan kanban" "Keep a measurement on the canvas for developers" = "Simpan pengukuran di kanvas untuk developer" "Keep appearance" = "Pertahankan tampilan" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Pertahankan tampilan mengonversi setiap nilai warna ke {profile} agar warna terlihat sama. Pertahankan nilai membiarkan angkanya apa adanya, sehingga warna bergeser." @@ -1080,6 +1085,7 @@ status = "draft — needs native review" "Remove all" = "Hapus semua" "Remove annotation" = "Hapus anotasi" "Remove auto layout" = "Hapus auto layout" +"Remove column" = "Hapus kolom" "Remove dev resource" = "Hapus sumber daya dev" "Remove export setting" = "Hapus pengaturan ekspor" "Remove from recents" = "Hapus dari terbaru" @@ -1093,6 +1099,7 @@ status = "draft — needs native review" "Remove min and max height" = "Hapus tinggi min dan maks" "Remove min and max width" = "Hapus lebar min dan maks" "Remove regions" = "Hapus area" +"Remove row" = "Hapus baris" "Remove slot" = "Hapus slot" "Remove starting point" = "Hapus titik awal" "Remove stop" = "Hapus stop" @@ -1144,6 +1151,7 @@ status = "draft — needs native review" "Restore main component" = "Pulihkan main component" "Restore this version" = "Pulihkan versi ini" "Restored “{name}”" = "“{name}” dipulihkan" +"Retrospective" = "Retrospektif" "Results" = "Hasil" "Resume" = "Lanjutkan" "Reverse the order" = "Balik urutan" @@ -1519,6 +1527,7 @@ status = "draft — needs native review" "WebSocket URL" = "URL WebSocket" "WebSocket URL copied" = "URL WebSocket disalin" "Wedge" = "Baji" +"Weekly planner" = "Perencana mingguan" "Weight" = "Ketebalan" "What you try here changes this preview and the code below, not the file" = "Yang Anda coba di sini mengubah pratinjau ini dan kode di bawah, bukan file-nya" "What changed since it was marked ready for dev" = "Yang berubah sejak ditandai siap untuk dev" @@ -1803,6 +1812,10 @@ status = "draft — needs native review" "board.shape" = "Bentuk dengan teks" "board.stamp" = "Stempel" "board.sticky" = "Catatan tempel" +"board.table" = "Tabel" +"board.tableEdit" = "Edit tabel" +"board.template" = "Templat papan" +"board.templates" = "Daftar templat papan" "board.timer" = "Pengatur waktu" "board.timerState" = "Status pengatur waktu" "board.vote" = "Sesi pemungutan suara" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 8234720f..e792ec72 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -47,6 +47,7 @@ status = "draft — needs native review" "Add another condition" = "条件を追加" "Add auto layout" = "オートレイアウトを追加" "Add background behind overlay" = "オーバーレイの背後に背景を追加" +"Add column" = "列を追加" "Add content to this slot" = "このスロットにコンテンツを追加" "Add dev resource" = "開発リソースを追加" "Add effect" = "エフェクトを追加" @@ -66,6 +67,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "変数 JSON からコレクションを追加・更新" "Add properties to make instances configurable" = "プロパティを追加してインスタンスを設定可能にする" "Add reaction" = "リアクションを追加" +"Add row" = "行を追加" "Add starting point" = "開始点を追加" "Add stroke" = "線を追加" "Add variant" = "バリアントを追加" @@ -184,6 +186,7 @@ status = "draft — needs native review" "Bottom left radius" = "左下の半径" "Bottom right radius" = "右下の半径" "Bound to {name}. Click to detach the variable." = "「{name}」を適用中。クリックで変数の割り当てを解除。" +"Brainstorm" = "ブレインストーミング" "Branch" = "ブランチ" "Branch archived" = "ブランチをアーカイブ済み" "Branch merged" = "ブランチをマージ済み" @@ -538,6 +541,7 @@ status = "draft — needs native review" "Flip text to the other side" = "テキストを反対側に反転" "Flip vertical" = "上下反転" "Flip width profile" = "幅プロファイルを反転" +"Flow chart" = "フローチャート" "Flow starting point" = "フローの開始点" "Flow starting points" = "フローの開始点" "Flows" = "フロー" @@ -692,6 +696,7 @@ status = "draft — needs native review" "Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" +"Kanban board" = "カンバンボード" "Keep a measurement on the canvas for developers" = "開発者向けに測定をキャンバスに残す" "Keep appearance" = "見た目を保持" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "「見た目を保持」はすべてのカラー値を {profile} に変換し、色の見た目を保つ。「値を保持」は数値をそのまま残すため、色が変わる。" @@ -1088,6 +1093,7 @@ status = "draft — needs native review" "Remove all" = "すべて削除" "Remove annotation" = "注釈を削除" "Remove auto layout" = "オートレイアウトを解除" +"Remove column" = "列を削除" "Remove dev resource" = "開発リソースを削除" "Remove export setting" = "書き出し設定を削除" "Remove from recents" = "最近使ったファイルから削除" @@ -1101,6 +1107,7 @@ status = "draft — needs native review" "Remove min and max height" = "最小と最大の高さを削除" "Remove min and max width" = "最小と最大の幅を削除" "Remove regions" = "領域を削除" +"Remove row" = "行を削除" "Remove slot" = "スロットを削除" "Remove starting point" = "開始点を削除" "Remove stop" = "カラー分岐点を削除" @@ -1154,6 +1161,7 @@ status = "draft — needs native review" "Restore main component" = "メインコンポーネントを復元" "Restore this version" = "このバージョンを復元" "Restored “{name}”" = "「{name}」を復元済み" +"Retrospective" = "振り返り" "Results" = "結果" "Resume" = "再開" "Reverse the order" = "順序を反転" @@ -1536,6 +1544,7 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket URL をコピー済み" "Websites" = "Webサイト" "Wedge" = "くさび" +"Weekly planner" = "週間プランナー" "Weight" = "太さ" "What changed since a saved version" = "保存したバージョンからの変更" "What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容" @@ -1822,6 +1831,10 @@ status = "draft — needs native review" "board.shape" = "テキスト付きシェイプ" "board.stamp" = "スタンプ" "board.sticky" = "付箋" +"board.table" = "表" +"board.tableEdit" = "表を編集" +"board.template" = "ボードテンプレート" +"board.templates" = "ボードテンプレートの一覧" "board.timer" = "タイマー" "board.timerState" = "タイマーの状態" "board.vote" = "投票セッション" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index c5f73101..8b935314 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -47,6 +47,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add another condition" = "კიდევ ერთი პირობის დამატება" "Add auto layout" = "ავტოგანლაგების დამატება" "Add background behind overlay" = "ფონის დამატება გადაფარვის უკან" +"Add column" = "სვეტის დამატება" "Add content to this slot" = "შიგთავსის დამატება ამ სლოტში" "Add dev resource" = "დეველოპერის რესურსის დამატება" "Add effect" = "ეფექტის დამატება" @@ -66,6 +67,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add or update collections from a variables JSON" = "კოლექციების დამატება ან განახლება ცვლადების JSON-იდან" "Add properties to make instances configurable" = "დაამატეთ თვისებები, რომ ინსტანსები კონფიგურირებადი გახდეს" "Add reaction" = "რეაქციის დამატება" +"Add row" = "მწკრივის დამატება" "Add starting point" = "საწყისი წერტილის დამატება" "Add stroke" = "კონტურის დამატება" "Add variant" = "ვარიანტის დამატება" @@ -185,6 +187,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Bottom" = "ქვემოთ" "Bottom left radius" = "ქვედა მარცხენა რადიუსი" "Bottom right radius" = "ქვედა მარჯვენა რადიუსი" +"Brainstorm" = "გონებრივი იერიში" "Branch" = "განშტოება" "Branch archived" = "განშტოება დაარქივებულია" "Branch merged" = "განშტოება შერწყმულია" @@ -544,6 +547,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Flip text to the other side" = "ტექსტის მეორე მხარეს გადატანა" "Flip vertical" = "ვერტიკალურად არეკვლა" "Flip width profile" = "სიგანის პროფილის არეკვლა" +"Flow chart" = "ბლოკ-სქემა" "Flow starting point" = "ნაკადის საწყისი წერტილი" "Flow starting points" = "ნაკადის საწყისი წერტილები" "Flows" = "ნაკადები" @@ -698,6 +702,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Jun" = "ივნ" "Just now" = "ახლახან" "Justify" = "ორმხრივი სწორება" +"Kanban board" = "კანბან დაფა" "Keep a measurement on the canvas for developers" = "გაზომვის შენახვა ტილოზე დეველოპერებისთვის" "Keep appearance" = "იერსახის შენარჩუნება" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„იერსახის შენარჩუნება“ ყველა ფერის მნიშვნელობას {profile}-ში გარდაქმნის, რომ ფერები იგივენაირად გამოჩნდეს. „მნიშვნელობების შენარჩუნება“ რიცხვებს უცვლელად ტოვებს, ამიტომ ფერები იცვლება." @@ -1099,6 +1104,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Remove all" = "ყველას წაშლა" "Remove annotation" = "ანოტაციის წაშლა" "Remove auto layout" = "ავტოგანლაგების მოხსნა" +"Remove column" = "სვეტის წაშლა" "Remove dev resource" = "დეველოპერის რესურსის წაშლა" "Remove export setting" = "ექსპორტის პარამეტრის წაშლა" "Remove from recents" = "ბოლოდროინდელებიდან ამოშლა" @@ -1112,6 +1118,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Remove min and max height" = "მინ. და მაქს. სიმაღლის მოხსნა" "Remove min and max width" = "მინ. და მაქს. სიგანის მოხსნა" "Remove regions" = "არეების წაშლა" +"Remove row" = "მწკრივის წაშლა" "Remove slot" = "სლოტის მოხსნა" "Remove starting point" = "საწყისი წერტილის წაშლა" "Remove stop" = "გადასვლის წერტილის წაშლა" @@ -1164,6 +1171,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Restore main component" = "მთავარი კომპონენტის აღდგენა" "Restore this version" = "ამ ვერსიის აღდგენა" "Restored “{name}”" = "„{name}“ აღდგა" +"Retrospective" = "რეტროსპექტივა" "Results" = "შედეგები" "Resume" = "გაგრძელება" "Reverse the order" = "რიგის შებრუნება" @@ -1550,6 +1558,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL დაკოპირდა" "Wedge" = "სოლი" +"Weekly planner" = "კვირის გეგმა" "Weight" = "სისქე" "What you try here changes this preview and the code below, not the file" = "რასაც აქ ცდით, ცვლის ამ გადახედვას და ქვემოთ კოდს და არა ფაილს" "What changed since it was marked ready for dev" = "რა შეიცვალა მზად დეველოპერისთვის მონიშვნის შემდეგ" @@ -1837,6 +1846,10 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "board.shape" = "ფიგურა ტექსტით" "board.stamp" = "შტამპი" "board.sticky" = "სტიკერი" +"board.table" = "ცხრილი" +"board.tableEdit" = "ცხრილის რედაქტირება" +"board.template" = "დაფის შაბლონი" +"board.templates" = "დაფის შაბლონების სია" "board.timer" = "ტაიმერი" "board.timerState" = "ტაიმერის მდგომარეობა" "board.vote" = "ხმის მიცემის სესია" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 86073183..6f497729 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -43,6 +43,7 @@ status = "draft — needs native review" "Add another condition" = "조건 추가" "Add auto layout" = "자동 레이아웃 추가" "Add background behind overlay" = "오버레이 뒤에 배경 추가" +"Add column" = "열 추가" "Add content to this slot" = "이 슬롯에 콘텐츠 추가" "Add dev resource" = "개발 리소스 추가" "Add effect" = "효과 추가" @@ -62,6 +63,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "변수 JSON에서 컬렉션 추가 또는 업데이트" "Add properties to make instances configurable" = "속성을 추가해 인스턴스를 설정 가능하게 만들기" "Add reaction" = "반응 추가" +"Add row" = "행 추가" "Add starting point" = "시작점 추가" "Add stroke" = "선 추가" "Add variant" = "배리언트 추가" @@ -174,6 +176,7 @@ status = "draft — needs native review" "Bottom" = "아래쪽" "Bottom left radius" = "왼쪽 아래 반경" "Bottom right radius" = "오른쪽 아래 반경" +"Brainstorm" = "브레인스토밍" "Branch" = "브랜치" "Branch archived" = "브랜치 보관됨" "Branch merged" = "브랜치 병합됨" @@ -517,6 +520,7 @@ status = "draft — needs native review" "Flip text to the other side" = "텍스트를 반대쪽으로 뒤집기" "Flip vertical" = "세로로 뒤집기" "Flip width profile" = "너비 프로필 뒤집기" +"Flow chart" = "순서도" "Flow starting point" = "플로우 시작점" "Flow starting points" = "플로우 시작점" "Flows" = "플로우" @@ -661,6 +665,7 @@ status = "draft — needs native review" "Join|stroke" = "선 연결" "Just now" = "방금" "Justify" = "양쪽 정렬" +"Kanban board" = "칸반 보드" "Keep a measurement on the canvas for developers" = "개발자를 위해 캔버스에 측정 유지" "Keep appearance" = "모양 유지" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "\"모양 유지\"는 모든 색상 값을 {profile}(으)로 변환해 색이 똑같아 보이게 함. \"값 유지\"는 숫자를 그대로 두므로 색이 달라짐." @@ -1039,6 +1044,7 @@ status = "draft — needs native review" "Remove all" = "모두 제거" "Remove annotation" = "주석 제거" "Remove auto layout" = "자동 레이아웃 제거" +"Remove column" = "열 제거" "Remove dev resource" = "개발 리소스 제거" "Remove export setting" = "내보내기 설정 제거" "Remove from server" = "서버에서 제거" @@ -1051,6 +1057,7 @@ status = "draft — needs native review" "Remove min and max height" = "최소 및 최대 높이 제거" "Remove min and max width" = "최소 및 최대 너비 제거" "Remove regions" = "영역 제거" +"Remove row" = "행 제거" "Remove slot" = "슬롯 제거" "Remove starting point" = "시작점 제거" "Remove stop" = "정지점 제거" @@ -1104,6 +1111,7 @@ status = "draft — needs native review" "Restore main component" = "메인 컴포넌트 복원" "Restore this version" = "이 버전 복원" "Restored “{name}”" = "\"{name}\" 복원됨" +"Retrospective" = "회고" "Results" = "결과" "Resume" = "재개" "Reverse the order" = "순서 뒤집기" @@ -1323,6 +1331,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "무엇이 문제인지 서버 운영자에게 알리기" "Temperature" = "색온도" "Template" = "템플릿" +"Templates" = "템플릿" "Terms" = "약관" "Text" = "텍스트" "Text align" = "텍스트 정렬" @@ -1466,6 +1475,7 @@ status = "draft — needs native review" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL 복사됨" "Wedge" = "쐐기" +"Weekly planner" = "주간 계획표" "Weight" = "굵기" "What changed since a saved version" = "저장된 버전 이후 변경된 내용" "What changed since it was marked ready for dev" = "개발 준비 완료로 표시된 뒤 변경된 내용" @@ -1746,6 +1756,10 @@ status = "draft — needs native review" "board.shape" = "텍스트가 있는 도형" "board.stamp" = "스탬프" "board.sticky" = "스티커 메모" +"board.table" = "표" +"board.tableEdit" = "표 편집" +"board.template" = "보드 템플릿" +"board.templates" = "보드 템플릿 목록" "board.timer" = "타이머" "board.timerState" = "타이머 상태" "board.vote" = "투표 세션" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index c36b7a30..7ed56cab 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -46,6 +46,7 @@ status = "draft — needs native review" "Add another condition" = "Nog een voorwaarde toevoegen" "Add auto layout" = "Auto layout toevoegen" "Add background behind overlay" = "Achtergrond achter overlay toevoegen" +"Add column" = "Kolom toevoegen" "Add content to this slot" = "Voeg inhoud toe aan dit slot" "Add dev resource" = "Dev-resource toevoegen" "Add effect" = "Effect toevoegen" @@ -65,6 +66,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Voeg verzamelingen toe of werk ze bij vanuit een variables-JSON" "Add properties to make instances configurable" = "Voeg eigenschappen toe om instances configureerbaar te maken" "Add reaction" = "Reactie toevoegen" +"Add row" = "Rij toevoegen" "Add starting point" = "Startpunt toevoegen" "Add stroke" = "Lijn toevoegen" "Add variant" = "Variant toevoegen" @@ -185,6 +187,7 @@ status = "draft — needs native review" "Bottom left radius" = "Radius linksonder" "Bottom right radius" = "Radius rechtsonder" "Bound to {name}. Click to detach the variable." = "Gekoppeld aan {name}. Klik om de variabele los te koppelen." +"Brainstorm" = "Brainstorm" "Branch" = "Branch" "Branch archived" = "Branch gearchiveerd" "Branch merged" = "Branch samengevoegd" @@ -544,6 +547,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Tekst naar de andere kant spiegelen" "Flip vertical" = "Verticaal spiegelen" "Flip width profile" = "Breedteprofiel spiegelen" +"Flow chart" = "Stroomdiagram" "Flow starting point" = "Flow-startpunt" "Flow starting points" = "Flow-startpunten" "Flows" = "Flows" @@ -698,6 +702,7 @@ status = "draft — needs native review" "Jun" = "jun." "Just now" = "Zojuist" "Justify" = "Uitvullen" +"Kanban board" = "Kanbanbord" "Keep a measurement on the canvas for developers" = "Een meting voor ontwikkelaars op het canvas houden" "Keep appearance" = "Uiterlijk behouden" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Kleur behouden converteert elke kleurwaarde naar {profile}, zodat kleuren er hetzelfde uitzien. Waarden behouden laat de getallen staan, waardoor kleuren verschuiven." @@ -1101,6 +1106,7 @@ status = "draft — needs native review" "Remove all" = "Alles verwijderen" "Remove annotation" = "Annotatie verwijderen" "Remove auto layout" = "Auto layout verwijderen" +"Remove column" = "Kolom verwijderen" "Remove dev resource" = "Dev-resource verwijderen" "Remove export setting" = "Exportinstelling verwijderen" "Remove from recents" = "Verwijderen uit onlangs geopend" @@ -1114,6 +1120,7 @@ status = "draft — needs native review" "Remove min and max height" = "Minimale en maximale hoogte verwijderen" "Remove min and max width" = "Minimale en maximale breedte verwijderen" "Remove regions" = "Gebieden verwijderen" +"Remove row" = "Rij verwijderen" "Remove slot" = "Vak verwijderen" "Remove starting point" = "Startpunt verwijderen" "Remove stop" = "Stop verwijderen" @@ -1166,6 +1173,7 @@ status = "draft — needs native review" "Restore main component" = "Hoofdcomponent terugzetten" "Restore this version" = "Deze versie terugzetten" "Restored “{name}”" = "“{name}” teruggezet" +"Retrospective" = "Retrospectief" "Results" = "Resultaten" "Resume" = "Hervatten" "Reverse the order" = "Volgorde omdraaien" @@ -1553,6 +1561,7 @@ status = "draft — needs native review" "WebSocket URL copied" = "WebSocket-URL gekopieerd" "Websites" = "Websites" "Wedge" = "Wedge" +"Weekly planner" = "Weekplanner" "Weight" = "Dikte" "What changed since it was marked ready for dev" = "Wat er veranderd is sinds het is gemarkeerd als klaar voor Dev Mode" "What is wrong with this file? The server's operator gets your report." = "Wat klopt er niet aan dit bestand? Je melding gaat naar de beheerder van de server." @@ -1840,6 +1849,10 @@ status = "draft — needs native review" "board.shape" = "Vorm met tekst" "board.stamp" = "Stempel" "board.sticky" = "Plakbriefje" +"board.table" = "Tabel" +"board.tableEdit" = "Tabel bewerken" +"board.template" = "Whiteboardsjabloon" +"board.templates" = "Whiteboardsjablonen weergeven" "board.timer" = "Timer" "board.timerState" = "Timerstatus" "board.vote" = "Stemronde" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 7d28ba49..d013bbe2 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -44,6 +44,7 @@ name = "Polski" "Add another condition" = "Dodaj kolejny warunek" "Add auto layout" = "Dodaj Auto layout" "Add background behind overlay" = "Dodaj tło za nakładką" +"Add column" = "Dodaj kolumnę" "Add content to this slot" = "Dodaj zawartość do tego gniazda" "Add dev resource" = "Dodaj zasób programistyczny" "Add effect" = "Dodaj efekt" @@ -63,6 +64,7 @@ name = "Polski" "Add or update collections from a variables JSON" = "Dodaj lub zaktualizuj kolekcje z pliku JSON ze zmiennymi" "Add properties to make instances configurable" = "Dodaj właściwości, aby umożliwić konfigurację instancji" "Add reaction" = "Dodaj reakcję" +"Add row" = "Dodaj wiersz" "Add starting point" = "Dodaj punkt startowy" "Add stroke" = "Dodaj obrys" "Add variant" = "Dodaj wariant" @@ -183,6 +185,7 @@ name = "Polski" "Bottom left radius" = "Promień lewego dolnego narożnika" "Bottom padding" = "Margines dolny" "Bottom right radius" = "Promień prawego dolnego narożnika" +"Brainstorm" = "Burza mózgów" "Branch" = "Gałąź" "Branch archived" = "Gałąź zarchiwizowana" "Branch merged" = "Gałąź scalona" @@ -543,6 +546,7 @@ name = "Polski" "Flip text to the other side" = "Przerzuć tekst na drugą stronę" "Flip vertical" = "Odbij w pionie" "Flip width profile" = "Odwróć profil szerokości" +"Flow chart" = "Schemat blokowy" "Flow starting point" = "Punkt startowy przepływu" "Flow starting points" = "Punkty startowe przepływu" "Flows" = "Przepływy" @@ -699,6 +703,7 @@ name = "Polski" "Jun" = "Cze" "Just now" = "Przed chwilą" "Justify" = "Wyjustuj" +"Kanban board" = "Tablica kanban" "Keep a measurement on the canvas for developers" = "Zachowaj pomiar na płótnie dla programistów" "Keep appearance" = "Zachowaj wygląd" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachowanie wyglądu konwertuje każdą wartość koloru na {profile}, aby kolory wyglądały tak samo. Zachowanie wartości pozostawia liczby bez zmian, co może zmienić odcienie barw." @@ -1104,6 +1109,7 @@ name = "Polski" "Remove all" = "Usuń wszystko" "Remove annotation" = "Usuń adnotację" "Remove auto layout" = "Usuń Auto layout" +"Remove column" = "Usuń kolumnę" "Remove dev resource" = "Usuń zasób programistyczny" "Remove export setting" = "Usuń ustawienie eksportu" "Remove from recents" = "Usuń z ostatnich" @@ -1117,6 +1123,7 @@ name = "Polski" "Remove min and max height" = "Usuń minimalną i maksymalną wysokość" "Remove min and max width" = "Usuń minimalną i maksymalną szerokość" "Remove regions" = "Usuń obszary" +"Remove row" = "Usuń wiersz" "Remove slot" = "Usuń gniazdo" "Remove starting point" = "Usuń punkt startowy" "Remove stop" = "Usuń punkt" @@ -1170,6 +1177,7 @@ name = "Polski" "Restore main component" = "Przywróć główny komponent" "Restore this version" = "Przywróć tę wersję" "Restored “{name}”" = "Przywrócono „{name}”" +"Retrospective" = "Retrospektywa" "Results" = "Wyniki" "Resume" = "Wznów" "Reverse the order" = "Odwróć kolejność" @@ -1563,6 +1571,7 @@ name = "Polski" "WebSocket URL" = "Adres WebSocket URL" "WebSocket URL copied" = "Skopiowano adres WebSocket URL" "Wedge" = "Klin" +"Weekly planner" = "Plan tygodnia" "Weight" = "Grubość" "What you try here changes this preview and the code below, not the file" = "To, co wypróbujesz tutaj, zmienia ten podgląd i kod poniżej, a nie plik" "What changed since it was marked ready for dev" = "Co się zmieniło od czasu oznaczenia jako gotowe do wdrożenia" @@ -1853,6 +1862,10 @@ name = "Polski" "board.shape" = "Kształt z tekstem" "board.stamp" = "Pieczątka" "board.sticky" = "Karteczka" +"board.table" = "Tabela" +"board.tableEdit" = "Edytuj tabelę" +"board.template" = "Szablon tablicy" +"board.templates" = "Lista szablonów tablicy" "board.timer" = "Minutnik" "board.timerState" = "Stan minutnika" "board.vote" = "Głosowanie" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 76a83d27..550c6f2e 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -41,6 +41,7 @@ status = "draft — needs native review" "Add another condition" = "Adicionar outra condição" "Add auto layout" = "Adicionar layout automático" "Add background behind overlay" = "Adicionar fundo atrás da sobreposição" +"Add column" = "Adicionar coluna" "Add content to this slot" = "Adicionar conteúdo a este slot" "Add dev resource" = "Adicionar recurso de desenvolvimento" "Add effect" = "Adicionar efeito" @@ -60,6 +61,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "Adicionar ou atualizar coleções a partir de um JSON de variáveis" "Add properties to make instances configurable" = "Adicione propriedades para tornar as instâncias configuráveis" "Add reaction" = "Adicionar reação" +"Add row" = "Adicionar linha" "Add starting point" = "Adicionar ponto de partida" "Add stroke" = "Adicionar traço" "Add variant" = "Adicionar variante" @@ -172,6 +174,7 @@ status = "draft — needs native review" "Bottom" = "Embaixo" "Bottom left radius" = "Raio inferior esquerdo" "Bottom right radius" = "Raio inferior direito" +"Brainstorm" = "Brainstorming" "Branch" = "Branch" "Branch archived" = "Branch arquivada" "Branch merged" = "Branch mesclada" @@ -516,6 +519,7 @@ status = "draft — needs native review" "Flip text to the other side" = "Passar o texto para o outro lado" "Flip vertical" = "Inverter verticalmente" "Flip width profile" = "Inverter perfil de largura" +"Flow chart" = "Fluxograma" "Flow starting point" = "Ponto de partida do fluxo" "Flow starting points" = "Pontos de partida de fluxo" "Flows" = "Fluxos" @@ -660,6 +664,7 @@ status = "draft — needs native review" "Join|stroke" = "Junção" "Just now" = "Agora mesmo" "Justify" = "Justificar" +"Kanban board" = "Quadro kanban" "Keep a measurement on the canvas for developers" = "Manter uma medição no canvas para desenvolvedores" "Keep appearance" = "Manter aparência" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Manter aparência converte cada valor de cor para {profile} para que as cores pareçam iguais. Manter valores deixa os números como estão, então as cores mudam." @@ -1038,6 +1043,7 @@ status = "draft — needs native review" "Remove all" = "Remover tudo" "Remove annotation" = "Remover anotação" "Remove auto layout" = "Remover layout automático" +"Remove column" = "Remover coluna" "Remove dev resource" = "Remover recurso de desenvolvimento" "Remove export setting" = "Remover configuração de exportação" "Remove from server" = "Remover do servidor" @@ -1050,6 +1056,7 @@ status = "draft — needs native review" "Remove min and max height" = "Remover altura mín. e máx." "Remove min and max width" = "Remover largura mín. e máx." "Remove regions" = "Remover regiões" +"Remove row" = "Remover linha" "Remove slot" = "Remover slot" "Remove starting point" = "Remover ponto de partida" "Remove stop" = "Remover parada" @@ -1103,6 +1110,7 @@ status = "draft — needs native review" "Restore main component" = "Restaurar componente principal" "Restore this version" = "Restaurar esta versão" "Restored “{name}”" = "“{name}” restaurada" +"Retrospective" = "Retrospectiva" "Results" = "Resultados" "Resume" = "Retomar" "Reverse the order" = "Inverter a ordem" @@ -1465,6 +1473,7 @@ status = "draft — needs native review" "WebSocket URL" = "URL do WebSocket" "WebSocket URL copied" = "URL do WebSocket copiada" "Wedge" = "Cunha" +"Weekly planner" = "Planejador semanal" "Weight" = "Espessura" "What changed since a saved version" = "O que mudou desde uma versão salva" "What changed since it was marked ready for dev" = "O que mudou desde que foi marcado como pronto para dev" @@ -1859,6 +1868,10 @@ status = "draft — needs native review" "board.shape" = "Forma com texto" "board.stamp" = "Carimbo" "board.sticky" = "Nota adesiva" +"board.table" = "Tabela" +"board.tableEdit" = "Editar tabela" +"board.template" = "Modelo de quadro" +"board.templates" = "Listar modelos de quadro" "board.timer" = "Timer" "board.timerState" = "Estado do timer" "board.vote" = "Sessão de votação" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index f383cf08..e141b9c8 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -44,6 +44,7 @@ name = "Русский" "Add another condition" = "Добавить ещё одно условие" "Add auto layout" = "Добавить автолейаут" "Add background behind overlay" = "Добавить фон за оверлеем" +"Add column" = "Добавить столбец" "Add content to this slot" = "Добавить содержимое в этот слот" "Add dev resource" = "Добавить ресурс для разработки" "Add effect" = "Добавить эффект" @@ -63,6 +64,7 @@ name = "Русский" "Add or update collections from a variables JSON" = "Добавить или обновить коллекции из JSON переменных" "Add properties to make instances configurable" = "Добавьте свойства, чтобы настраивать экземпляры" "Add reaction" = "Добавить реакцию" +"Add row" = "Добавить строку" "Add starting point" = "Добавить начальную точку" "Add stroke" = "Добавить обводку" "Add variant" = "Добавить вариант" @@ -183,6 +185,7 @@ name = "Русский" "Bottom left radius" = "Радиус нижнего левого угла" "Bottom padding" = "Нижний внутренний отступ" "Bottom right radius" = "Радиус нижнего правого угла" +"Brainstorm" = "Мозговой штурм" "Branch" = "Ветка" "Branch archived" = "Ветка архивирована" "Branch merged" = "Ветка объединена" @@ -543,6 +546,7 @@ name = "Русский" "Flip text to the other side" = "Перенести текст на другую сторону" "Flip vertical" = "Отразить по вертикали" "Flip width profile" = "Отразить профиль ширины" +"Flow chart" = "Блок-схема" "Flow starting point" = "Начальная точка сценария" "Flow starting points" = "Начальные точки сценариев" "Flows" = "Сценарии" @@ -699,6 +703,7 @@ name = "Русский" "Jun" = "июн." "Just now" = "Только что" "Justify" = "По ширине" +"Kanban board" = "Канбан-доска" "Keep a measurement on the canvas for developers" = "Оставить измерение на холсте для разработчиков" "Keep appearance" = "Сохранить внешний вид" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "«Сохранить внешний вид» преобразует все значения цветов в {profile}, сохраняя их вид. «Сохранить значения» оставляет числа без изменений, поэтому цвета меняются." @@ -1104,6 +1109,7 @@ name = "Русский" "Remove all" = "Удалить всё" "Remove annotation" = "Удалить аннотацию" "Remove auto layout" = "Убрать автолейаут" +"Remove column" = "Удалить столбец" "Remove dev resource" = "Удалить ресурс для разработки" "Remove export setting" = "Удалить настройку экспорта" "Remove from recents" = "Убрать из недавних" @@ -1117,6 +1123,7 @@ name = "Русский" "Remove min and max height" = "Убрать минимальную и максимальную высоту" "Remove min and max width" = "Убрать минимальную и максимальную ширину" "Remove regions" = "Удалить области" +"Remove row" = "Удалить строку" "Remove slot" = "Убрать слот" "Remove starting point" = "Убрать начальную точку" "Remove stop" = "Удалить точку градиента" @@ -1170,6 +1177,7 @@ name = "Русский" "Restore main component" = "Восстановить основной компонент" "Restore this version" = "Восстановить эту версию" "Restored “{name}”" = "«{name}» восстановлено" +"Retrospective" = "Ретроспектива" "Results" = "Результаты" "Resume" = "Продолжить" "Reverse the order" = "Обратить порядок" @@ -1563,6 +1571,7 @@ name = "Русский" "WebSocket URL" = "URL WebSocket" "WebSocket URL copied" = "URL WebSocket скопирован" "Wedge" = "Клин" +"Weekly planner" = "План на неделю" "Weight" = "Толщина" "What you try here changes this preview and the code below, not the file" = "Здесь меняются только предпросмотр и код ниже; файл остаётся без изменений" "What changed since it was marked ready for dev" = "Изменения после отметки «Готово к разработке»" @@ -1853,6 +1862,10 @@ name = "Русский" "board.shape" = "Фигура с текстом" "board.stamp" = "Штамп" "board.sticky" = "Стикер" +"board.table" = "Таблица" +"board.tableEdit" = "Изменить таблицу" +"board.template" = "Шаблон доски" +"board.templates" = "Список шаблонов доски" "board.timer" = "Таймер" "board.timerState" = "Состояние таймера" "board.vote" = "Голосование" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 46533735..1af7c5e4 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -44,6 +44,7 @@ name = "Svenska" "Add another condition" = "Lägg till ett villkor till" "Add auto layout" = "Lägg till auto layout" "Add background behind overlay" = "Lägg till bakgrund bakom överlägg" +"Add column" = "Lägg till kolumn" "Add content to this slot" = "Lägg till innehåll i facket" "Add dev resource" = "Lägg till utvecklarresurs" "Add effect" = "Lägg till effekt" @@ -63,6 +64,7 @@ name = "Svenska" "Add or update collections from a variables JSON" = "Lägg till eller uppdatera samlingar från en variabel-JSON" "Add properties to make instances configurable" = "Lägg till egenskaper för att göra instanser konfigurerbara" "Add reaction" = "Lägg till reaktion" +"Add row" = "Lägg till rad" "Add starting point" = "Lägg till startpunkt" "Add stroke" = "Lägg till kontur" "Add variant" = "Lägg till variant" @@ -183,6 +185,7 @@ name = "Svenska" "Bottom left radius" = "Radie nere till vänster" "Bottom padding" = "Nedre padding" "Bottom right radius" = "Radie nere till höger" +"Brainstorm" = "Idéstorm" "Branch" = "Gren" "Branch archived" = "Grenen har arkiverats" "Branch merged" = "Grenen har sammanfogats" @@ -543,6 +546,7 @@ name = "Svenska" "Flip text to the other side" = "Vänd texten till andra sidan" "Flip vertical" = "Vänd lodrätt" "Flip width profile" = "Vänd breddprofil" +"Flow chart" = "Flödesschema" "Flow starting point" = "Startpunkt för flöde" "Flow starting points" = "Startpunkter för flöden" "Flows" = "Flöden" @@ -699,6 +703,7 @@ name = "Svenska" "Jun" = "jun" "Just now" = "Nyss" "Justify" = "Marginaljustera" +"Kanban board" = "Kanbantavla" "Keep a measurement on the canvas for developers" = "Behåll ett mått på arbetsytan för utvecklare" "Keep appearance" = "Behåll utseende" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Behåll utseende konverterar varje färgvärde till {profile} så att färgerna ser likadana ut. Behåll värden lämnar siffrorna som de är, så färgerna förskjuts." @@ -1104,6 +1109,7 @@ name = "Svenska" "Remove all" = "Ta bort alla" "Remove annotation" = "Ta bort anteckning" "Remove auto layout" = "Ta bort auto layout" +"Remove column" = "Ta bort kolumn" "Remove dev resource" = "Ta bort utvecklarresurs" "Remove export setting" = "Ta bort exportinställning" "Remove from recents" = "Ta bort från Senaste" @@ -1117,6 +1123,7 @@ name = "Svenska" "Remove min and max height" = "Ta bort min- och maxhöjd" "Remove min and max width" = "Ta bort min- och maxbredd" "Remove regions" = "Ta bort områden" +"Remove row" = "Ta bort rad" "Remove slot" = "Ta bort plats" "Remove starting point" = "Ta bort startpunkt" "Remove stop" = "Ta bort stopp" @@ -1170,6 +1177,7 @@ name = "Svenska" "Restore main component" = "Återställ huvudkomponent" "Restore this version" = "Återställ den här versionen" "Restored “{name}”" = "Återställde ”{name}”" +"Retrospective" = "Retrospektiv" "Results" = "Resultat" "Resume" = "Återuppta" "Reverse the order" = "Vänd på ordningen" @@ -1563,6 +1571,7 @@ name = "Svenska" "WebSocket URL" = "WebSocket-URL" "WebSocket URL copied" = "WebSocket-URL kopierad" "Wedge" = "Kil" +"Weekly planner" = "Veckoplanering" "Weight" = "Tjocklek" "What you try here changes this preview and the code below, not the file" = "Det du provar här ändrar den här förhandsgranskningen och koden nedan, inte filen" "What changed since it was marked ready for dev" = "Vad som har ändrats sedan den markerades som klar för utveckling" @@ -1854,6 +1863,10 @@ name = "Svenska" "board.shape" = "Form med text" "board.stamp" = "Stämpel" "board.sticky" = "Lapp" +"board.table" = "Tabell" +"board.tableEdit" = "Redigera tabell" +"board.template" = "Whiteboardmall" +"board.templates" = "Lista whiteboardmallar" "board.timer" = "Timer" "board.timerState" = "Timerstatus" "board.vote" = "Omröstning" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 6eeb7774..958dcb59 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -45,6 +45,7 @@ name = "Türkçe" "Add another condition" = "Başka bir koşul ekle" "Add auto layout" = "Otomatik yerleşim ekle" "Add background behind overlay" = "Bindirmenin arkasına arka plan ekle" +"Add column" = "Sütun ekle" "Add content to this slot" = "Bu yuvaya içerik ekle" "Add dev resource" = "Geliştirici kaynağı ekle" "Add effect" = "Efekt ekle" @@ -64,6 +65,7 @@ name = "Türkçe" "Add or update collections from a variables JSON" = "Bir değişken JSON'undan koleksiyon ekleyin veya güncelleyin" "Add properties to make instances configurable" = "Örnekleri yapılandırılabilir kılmak için özellik ekleyin" "Add reaction" = "Tepki ekle" +"Add row" = "Satır ekle" "Add starting point" = "Başlangıç noktası ekle" "Add stroke" = "Kontur ekle" "Add variant" = "Varyant ekle" @@ -181,6 +183,7 @@ name = "Türkçe" "Bottom" = "Alt" "Bottom left radius" = "Sol alt yarıçap" "Bottom right radius" = "Sağ alt yarıçap" +"Brainstorm" = "Beyin fırtınası" "Branch" = "Dal" "Branch archived" = "Dal arşivlendi" "Branch merged" = "Dal birleştirildi" @@ -532,6 +535,7 @@ name = "Türkçe" "Flip text to the other side" = "Metni diğer tarafa çevir" "Flip vertical" = "Dikey çevir" "Flip width profile" = "Genişlik profilini çevir" +"Flow chart" = "Akış şeması" "Flow starting point" = "Akış başlangıç noktası" "Flow starting points" = "Akış başlangıç noktaları" "Flows" = "Akışlar" @@ -685,6 +689,7 @@ name = "Türkçe" "Jun" = "Haz" "Just now" = "Az önce" "Justify" = "İki yana yasla" +"Kanban board" = "Kanban panosu" "Keep a measurement on the canvas for developers" = "Geliştiriciler için tuvalde bir ölçüm tut" "Keep appearance" = "Görünümü koru" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Görünümü koru, renkler aynı görünsün diye her renk değerini {profile} profiline dönüştürür. Değerleri koru, sayıları olduğu gibi bırakır; bu yüzden renkler kayar." @@ -1080,6 +1085,7 @@ name = "Türkçe" "Remove all" = "Tümünü kaldır" "Remove annotation" = "Ek açıklamayı kaldır" "Remove auto layout" = "Otomatik yerleşimi kaldır" +"Remove column" = "Sütunu kaldır" "Remove dev resource" = "Geliştirici kaynağını kaldır" "Remove export setting" = "Dışa aktarma ayarını kaldır" "Remove from recents" = "Son kullanılanlardan kaldır" @@ -1093,6 +1099,7 @@ name = "Türkçe" "Remove min and max height" = "En küçük ve en büyük yüksekliği kaldır" "Remove min and max width" = "En küçük ve en büyük genişliği kaldır" "Remove regions" = "Bölgeleri kaldır" +"Remove row" = "Satırı kaldır" "Remove slot" = "Yuvayı kaldır" "Remove starting point" = "Başlangıç noktasını kaldır" "Remove stop" = "Durağı kaldır" @@ -1144,6 +1151,7 @@ name = "Türkçe" "Restore main component" = "Ana bileşeni geri yükle" "Restore this version" = "Bu sürümü geri yükle" "Restored “{name}”" = "“{name}” geri yüklendi" +"Retrospective" = "Retrospektif" "Results" = "Sonuçlar" "Resume" = "Sürdür" "Reverse the order" = "Sırayı ters çevir" @@ -1519,6 +1527,7 @@ name = "Türkçe" "WebSocket URL" = "WebSocket URL'si" "WebSocket URL copied" = "WebSocket URL'si kopyalandı" "Wedge" = "Kama" +"Weekly planner" = "Haftalık planlayıcı" "Weight" = "Kalınlık" "What you try here changes this preview and the code below, not the file" = "Burada denedikleriniz dosyayı değil, bu önizlemeyi ve aşağıdaki kodu değiştirir" "What changed since it was marked ready for dev" = "Geliştirmeye hazır olarak işaretlendiğinden beri neler değişti" @@ -1803,6 +1812,10 @@ name = "Türkçe" "board.shape" = "Metinli şekil" "board.stamp" = "Damga" "board.sticky" = "Yapışkan not" +"board.table" = "Tablo" +"board.tableEdit" = "Tabloyu düzenle" +"board.template" = "Pano şablonu" +"board.templates" = "Pano şablonlarını listele" "board.timer" = "Zamanlayıcı" "board.timerState" = "Zamanlayıcı durumu" "board.vote" = "Oylama oturumu" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 8c85f860..4fe87062 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -44,6 +44,7 @@ status = "draft — needs native review" "Add another condition" = "添加其他条件" "Add auto layout" = "添加自动布局" "Add background behind overlay" = "在浮层后添加背景" +"Add column" = "添加列" "Add content to this slot" = "向此插槽添加内容" "Add dev resource" = "添加开发资源" "Add effect" = "添加效果" @@ -63,6 +64,7 @@ status = "draft — needs native review" "Add or update collections from a variables JSON" = "从变量 JSON 添加或更新集合" "Add properties to make instances configurable" = "添加属性,使实例可配置" "Add reaction" = "添加表情回应" +"Add row" = "添加行" "Add starting point" = "添加起点" "Add stroke" = "添加描边" "Add variant" = "添加变体" @@ -175,6 +177,7 @@ status = "draft — needs native review" "Bottom" = "底部" "Bottom left radius" = "左下圆角" "Bottom right radius" = "右下圆角" +"Brainstorm" = "头脑风暴" "Branch" = "分支" "Branch archived" = "分支已归档" "Branch merged" = "分支已合并" @@ -521,6 +524,7 @@ status = "draft — needs native review" "Flip text to the other side" = "将文本翻转到另一侧" "Flip vertical" = "垂直翻转" "Flip width profile" = "翻转宽度配置" +"Flow chart" = "流程图" "Flow starting point" = "流程起点" "Flow starting points" = "流程起点" "Flows" = "流程" @@ -666,6 +670,7 @@ status = "draft — needs native review" "Join|stroke" = "转角" "Just now" = "刚刚" "Justify" = "两端对齐" +"Kanban board" = "看板" "Keep a measurement on the canvas for developers" = "为开发者在画布上保留测量" "Keep appearance" = "保持外观" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "“保持外观”会将每个颜色值转换为 {profile},使颜色看起来不变。“保持数值”则保留数值不变,颜色会有偏移。" @@ -1047,6 +1052,7 @@ status = "draft — needs native review" "Remove all" = "全部移除" "Remove annotation" = "移除标注" "Remove auto layout" = "移除自动布局" +"Remove column" = "删除列" "Remove dev resource" = "移除开发资源" "Remove export setting" = "移除导出设置" "Remove from server" = "从服务器移除" @@ -1059,6 +1065,7 @@ status = "draft — needs native review" "Remove min and max height" = "移除最小和最大高度" "Remove min and max width" = "移除最小和最大宽度" "Remove regions" = "移除区域" +"Remove row" = "删除行" "Remove slot" = "移除插槽" "Remove starting point" = "移除起点" "Remove stop" = "移除色标" @@ -1112,6 +1119,7 @@ status = "draft — needs native review" "Restore main component" = "恢复主组件" "Restore this version" = "恢复此版本" "Restored “{name}”" = "已恢复“{name}”" +"Retrospective" = "回顾" "Results" = "结果" "Resume" = "继续" "Reverse the order" = "反转顺序" @@ -1331,6 +1339,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "告诉服务器的运营者它有什么问题" "Temperature" = "色温" "Template" = "模板" +"Templates" = "模板" "Terms" = "条款" "Text" = "文本" "Text align" = "文本对齐" @@ -1474,6 +1483,7 @@ status = "draft — needs native review" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "已复制 WebSocket URL" "Wedge" = "楔形" +"Weekly planner" = "每周计划" "Weight" = "粗细" "What changed since a saved version" = "自已保存版本以来的更改" "What changed since it was marked ready for dev" = "自标记为可供开发以来的更改" @@ -1754,6 +1764,10 @@ status = "draft — needs native review" "board.shape" = "带文字的形状" "board.stamp" = "印章" "board.sticky" = "便利贴" +"board.table" = "表格" +"board.tableEdit" = "编辑表格" +"board.template" = "白板模板" +"board.templates" = "列出白板模板" "board.timer" = "计时器" "board.timerState" = "计时器状态" "board.vote" = "投票" diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/board_bar.rs index 2d58d735..c1a76a4d 100644 --- a/crates/ui-egui/src/board_bar.rs +++ b/crates/ui-egui/src/board_bar.rs @@ -96,6 +96,7 @@ struct Has { shape: Option, text: bool, connector: Option<(Routing, bool)>, + table: bool, } /// What the selected board objects take from the bar (layers that aren't board objects, a section @@ -116,10 +117,11 @@ fn has(app: &FigApp) -> Option { h.connector = Some((c.routing, n.stroke.cap_end != StrokeCap::None)); } Some(BoardItem::Label { .. }) => h.text = true, + Some(BoardItem::Table) => h.table = true, Some(BoardItem::Stamp { .. }) | None => {} } } - (h.color || h.text || h.connector.is_some()).then_some(h) + (h.color || h.text || h.connector.is_some() || h.table).then_some(h) } const CELL: f32 = 28.0; @@ -144,6 +146,9 @@ impl Has { if self.connector.is_some() { w += 4.0 * (CELL + 2.0) + PART_END; } + if self.table { + w += crate::board_picker::TABLE_PART_W; + } w } } @@ -188,6 +193,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if h.text { x = size_part(app, ui, x, cy, &t); } + if h.table { + x = crate::board_picker::table_part(app, ui, x, cy, &t); + } if let Some((routing, arrow)) = h.connector { line_part(app, ui, (routing, arrow), pos2(x, cy), &t); } @@ -274,7 +282,7 @@ fn line_part(app: &mut FigApp, ui: &mut Ui, line: (Routing, bool), at: Pos2, t: } /// A thin separator after `x`; where the next part starts. -fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 { +pub(crate) fn divider(ui: &Ui, t: &Tokens, x: f32, cy: f32) -> f32 { ui.painter().line_segment([pos2(x + 2.0, cy - 10.0), pos2(x + 2.0, cy + 10.0)], Stroke::new(1.0, t.border)); x + 8.0 } diff --git a/crates/ui-egui/src/board_picker.rs b/crates/ui-egui/src/board_picker.rs new file mode 100644 index 00000000..6f7e509a --- /dev/null +++ b/crates/ui-egui/src/board_picker.rs @@ -0,0 +1,162 @@ +//! Boards in the UI, continued: the toolbar's templates picker (a popover of the board templates, +//! each with a picture of itself, and a table) and the table's part of the bar over the selection +//! (add or remove a row or a column). + +use std::collections::HashMap; + +use egui::{Align2, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_engine::board_templates::TEMPLATES; +use serde_json::json; + +use crate::FigApp; +use crate::a11y; +use crate::i18n::tr; +use crate::icons::{self, Icon}; +use crate::theme::{Tokens, regular}; +use crate::toolbar::tool_cell; + +/// The picker's key while it is open (`app.ui.tool_flyout`). +const KEY: &str = "board_templates"; +const CARD: egui::Vec2 = vec2(136.0, 104.0); +const PICTURE_H: f32 = 76.0; +const COLUMNS: usize = 3; +const PAD: f32 = 10.0; +const GAP: f32 = 8.0; +const TITLE_H: f32 = 26.0; + +/// The templates button at `at` (its left edge, the toolbar's middle) and, while open, its picker +/// above the toolbar (whose top is `top`). Where the next button starts. +pub(crate) fn button(app: &mut FigApp, ui: &mut Ui, at: Pos2, top: f32, t: &Tokens) -> f32 { + let r = Rect::from_min_size(pos2(at.x, at.y - 16.0), vec2(32.0, 32.0)); + let open = app.ui.tool_flyout == Some(KEY); + let resp = tool_cell(ui, t, r, "board_templates", Icon::Templates, open, tr("Templates")); + if open { + a11y::popup(&mut app.ui.nav, resp.id, area_id()); + } + if resp.clicked() { + app.ui.tool_flyout = if open { None } else { Some(KEY) }; + } + if app.ui.tool_flyout == Some(KEY) { + picker(app, ui, r, top - 8.0, t); + } + r.max.x + 4.0 +} + +fn area_id() -> egui::Id { + egui::Id::unique("board_templates_area") +} + +/// A template's picture: the template built on a board of its own and drawn small. +fn picture(ctx: &egui::Context, id: &str) -> Option { + let doc = figcraft_engine::board_templates::preview(id).ok()?; + let layers = doc.node(doc.first_page()?)?.children.clone(); + let b = layers.iter().filter_map(|l| figcraft_render::export_bounds(&doc, *l)).reduce(|a, b| a.union(b))?; + if !(b.width() > 0.0 && b.height() > 0.0) { + return None; + } + let fit = (f64::from(CARD.x - 16.0) / b.width()).min(f64::from(PICTURE_H - 12.0) / b.height()); + let pix = figcraft_render::render_area(&doc, &layers, b, (fit * f64::from(ctx.pixels_per_point())).min(1.0))?; + let image = crate::render_worker::screen_image(&pix, doc.color_profile); + Some(ctx.load_texture(format!("board-template-{id}"), image, egui::TextureOptions::LINEAR)) +} + +/// The templates' pictures, drawn the first time the picker opens. +#[derive(Clone, Default)] +struct Pictures(HashMap<&'static str, Option>); + +/// The picker: a card for each template and one for a table, its bottom at `bottom`, from the +/// button `anchor`'s right edge leftwards (inside the canvas). +fn picker(app: &mut FigApp, ui: &mut Ui, anchor: Rect, bottom: f32, t: &Tokens) { + let ctx = ui.ctx().clone(); + let mut pictures: Pictures = ctx.data(|d| d.get_temp(area_id())).unwrap_or_default(); + for tpl in TEMPLATES { + pictures.0.entry(tpl.id).or_insert_with(|| picture(&ctx, tpl.id)); + } + let count = TEMPLATES.len() + 1; + let rows = count.div_ceil(COLUMNS); + let size = vec2(COLUMNS as f32 * (CARD.x + GAP) - GAP + 2.0 * PAD, TITLE_H + rows as f32 * (CARD.y + GAP) - GAP + 2.0 * PAD); + let canvas = app.canvas.rect; + let x = (anchor.max.x - size.x).min(canvas.max.x - size.x - 8.0).max(canvas.min.x + 8.0); + let mut chosen = None; + egui::Area::new(area_id()).fixed_pos(pos2(x, bottom - size.y)).order(egui::Order::Foreground).show(&ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + a11y::container_bounds(ui, rect); + let p = ui.painter(); + p.rect_filled(rect, CornerRadius::same(13), t.menu); + p.text(pos2(rect.min.x + PAD + 2.0, rect.min.y + PAD + 8.0), Align2::LEFT_CENTER, tr("Templates"), crate::theme::semibold(12.0), t.menu_text); + for i in 0..count { + let (col, row) = (i % COLUMNS, i / COLUMNS); + let at = pos2(rect.min.x + PAD + col as f32 * (CARD.x + GAP), rect.min.y + PAD + TITLE_H + row as f32 * (CARD.y + GAP)); + let r = Rect::from_min_size(at, CARD); + let tpl = TEMPLATES.get(i); + let label = tpl.map_or(tr("Table"), |tpl| tr(tpl.name)); + let resp = ui.interact(r, ui.scope_id().with(("board_template", i)), Sense::click()); + a11y::menu_item(&resp, label, "", None); + a11y::own_focus(&resp); + let p = ui.painter(); + if resp.hovered() || resp.has_focus() { + p.rect_filled(r, CornerRadius::same(8), t.menu_hover); + } + let pic = Rect::from_min_size(r.min + vec2(4.0, 4.0), vec2(CARD.x - 8.0, PICTURE_H)); + p.rect_filled(pic, CornerRadius::same(6), crate::theme::c32(figcraft_doc::BOARD_PAGE)); + p.rect_stroke(pic, CornerRadius::same(6), Stroke::new(1.0, t.border), StrokeKind::Inside); + match tpl.and_then(|tpl| pictures.0.get(tpl.id)).and_then(Option::as_ref) { + Some(tex) => { + let s = tex.size_vec2() / ctx.pixels_per_point(); + let k = ((pic.width() - 12.0) / s.x).min((pic.height() - 12.0) / s.y).min(1.0); + p.image( + tex.id(), + Rect::from_center_size(pic.center(), s * k), + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } + // The table (and a template whose picture couldn't be drawn): its mark. + None => icons::paint( + p, + Rect::from_center_size(pic.center(), vec2(32.0, 32.0)), + if tpl.is_some() { Icon::Templates } else { Icon::Table }, + egui::Color32::from_gray(90), + ), + } + p.text(pos2(r.min.x + 6.0, pic.max.y + 13.0), Align2::LEFT_CENTER, label, regular(12.0), t.menu_text); + if resp.clicked() { + chosen = Some(i); + } + } + }); + ctx.data_mut(|d| d.insert_temp(area_id(), pictures)); + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.tool_flyout = None; + } + if let Some(i) = chosen { + let (cmd, params) = match TEMPLATES.get(i) { + Some(tpl) => ("board.template", json!({"name": tpl.id})), + None => ("board.table", json!({})), + }; + app.run(cmd, params); + app.ui.tool_flyout = None; + } +} + +/// The table's part of the bar over the selection from `x`: add a row or a column at the end, +/// remove the last row or column. Where the next part starts. +pub(crate) fn table_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { + let cells = [ + (Icon::AddRow, tr("Add row"), "addRow"), + (Icon::RemoveRow, tr("Remove row"), "removeRow"), + (Icon::AddColumn, tr("Add column"), "addColumn"), + (Icon::RemoveColumn, tr("Remove column"), "removeColumn"), + ]; + for (icon, tip, action) in cells { + let r = Rect::from_center_size(pos2(x + 14.0, cy), vec2(28.0, 28.0)); + if tool_cell(ui, t, r, ("bb_table", action), icon, false, tip).clicked() { + app.run("board.tableEdit", json!({"action": action})); + } + x += 30.0; + } + crate::board_bar::divider(ui, t, x, cy) +} + +/// The table part's width (with the gap after it). +pub(crate) const TABLE_PART_W: f32 = 4.0 * 30.0 + 8.0; diff --git a/crates/ui-egui/src/board_picker_tests.rs b/crates/ui-egui/src/board_picker_tests.rs new file mode 100644 index 00000000..c5860525 --- /dev/null +++ b/crates/ui-egui/src/board_picker_tests.rs @@ -0,0 +1,83 @@ +//! Boards in the UI: the toolbar's templates picker (pictures, a template or a table placed with +//! a click) and the table's part of the bar over the selection. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2}; +use figcraft_doc::{BoardItem, NodeId}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, has_text, text_center}; + +fn click(u: &mut Ui, p: Pos2) { + for pressed in [true, false] { + u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + u.frame(vec![]); +} + +fn board() -> Ui { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + u +} + +/// The templates button: left of the Design panel's button at the board toolbar's right end. +fn templates_button(u: &Ui) -> Pos2 { + let p = crate::toolbar::pill_rect(u.app.canvas.rect, crate::toolbar::bar_width(&u.app)); + pos2(p.max.x - 8.0 - 32.0 - 4.0 - 16.0, p.center().y) +} + +fn count(u: &Ui, f: impl Fn(&BoardItem) -> bool) -> usize { + u.app.session.doc().unwrap().nodes().filter(|n| n.board.as_ref().is_some_and(&f)).count() +} + +#[test] +fn the_templates_picker_places_a_template_or_a_table() { + let mut u = board(); + let at = templates_button(&u); + click(&mut u, at); + assert_eq!(u.app.ui.tool_flyout, Some("board_templates")); + let shapes = flat(&u.frame_output(vec![]).shapes); + for name in ["Retrospective", "Brainstorm", "Weekly planner", "Flow chart", "Kanban board", "Table"] { + assert!(has_text(&shapes, name), "{name} in the picker"); + } + let pictures = shapes.iter().filter(|s| matches!(s, egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default())).count(); + assert!(pictures >= 5, "each template has its picture ({pictures})"); + click(&mut u, text_center(&shapes, "Flow chart")); + assert_eq!(u.app.ui.tool_flyout, None, "a pick closes the picker"); + assert_eq!(count(&u, |b| matches!(b, BoardItem::Connector(_))), 6); + assert!(!u.app.session.selection().is_empty(), "what it placed is selected"); + // Again, for a table. + click(&mut u, at); + let shapes = flat(&u.frame_output(vec![]).shapes); + click(&mut u, text_center(&shapes, "Table")); + assert_eq!(count(&u, |b| *b == BoardItem::Table), 1); + // Escape closes it. + click(&mut u, at); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert_eq!(u.app.ui.tool_flyout, None); +} + +#[test] +fn the_selection_bar_adds_and_removes_a_tables_rows_and_columns() { + let mut u = board(); + let t = NodeId(u.app.session.execute("board.table", &json!({"x": 0, "y": 0, "rows": 2, "columns": 2})).unwrap()["id"].as_u64().unwrap()); + u.frame(vec![]); + u.frame(vec![]); + let size = |u: &Ui| { + let doc = u.app.session.doc().unwrap(); + let n = doc.node(t).unwrap(); + let rows: Vec<_> = n.children.iter().filter(|c| !doc.node(**c).unwrap().child_layout.absolute).collect(); + (rows.len(), doc.node(*rows[0]).unwrap().children.len()) + }; + // Add row, remove row, add column, remove column: 30 apart, 8 in. + for (i, want) in [(0, (3, 2)), (1, (2, 2)), (2, (2, 3)), (3, (2, 2))] { + // The bar follows the table's middle as it widens. + let r = u.ctx.memory(|m| m.area_rect(egui::Id::unique("board_bar"))).expect("the bar shows for a table"); + click(&mut u, pos2(r.min.x + 8.0 + i as f32 * 30.0 + 14.0, r.center().y)); + assert_eq!(size(&u), want, "cell {i}"); + } +} diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 43be97f4..5b4da85e 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -133,6 +133,7 @@ fn dynamic_labels() -> Vec<&'static str> { // Board shapes and stamps (the board tools' flyouts and the contextual bar). v.extend(figcraft_doc::BoardShape::ALL.iter().map(|b| b.label())); v.extend(figcraft_doc::Stamp::ALL.iter().map(|b| b.label())); + v.extend(figcraft_engine::board_templates::TEMPLATES.iter().map(|t| t.name)); v.extend(Group::ALL.iter().map(|g| g.label())); v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other])); v.extend(DATA_LABELS); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f56afc5c..c8ee27e3 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -203,6 +203,14 @@ icons! { PenNib, /// A board file (and the sticky note tool): a note with its bottom corner folded up. Board, + /// Board templates: a board laid out in blocks, a note and lines of text. A table: a grid. + /// The table bar's rows and columns: a strip with a plus or a minus beyond its end. + Templates, + Table, + AddRow, + RemoveRow, + AddColumn, + RemoveColumn, /// Board tools: a chisel-tipped marker; a broad highlighter over its band; a connector /// between two boxes; shapes (a square behind a circle); a stamp (a badge with a check). Marker, @@ -1172,6 +1180,36 @@ fn draw(p: &P, icon: Icon) { p.line(5.0, 6.5, 11.0, 6.5); p.line(5.0, 9.5, 8.0, 9.5); } + Templates => { + p.rect(1.5, 2.5, 14.5, 13.5, 1.5); + p.fill_rect(3.5, 4.5, 7.5, 8.5, 0.5); + p.line(9.5, 5.5, 12.5, 5.5); + p.line(9.5, 7.5, 12.5, 7.5); + p.line(3.5, 11.0, 12.5, 11.0); + } + Table => { + p.rect(1.5, 2.5, 14.5, 13.5, 1.0); + p.line(1.5, 6.5, 14.5, 6.5); + p.line(1.5, 10.0, 14.5, 10.0); + p.line(6.0, 2.5, 6.0, 13.5); + p.line(10.0, 2.5, 10.0, 13.5); + } + AddRow | RemoveRow => { + p.rect(1.5, 2.5, 14.5, 8.5, 1.0); + p.line(8.0, 2.5, 8.0, 8.5); + p.line(5.5, 12.5, 10.5, 12.5); + if icon == AddRow { + p.line(8.0, 10.0, 8.0, 15.0); + } + } + AddColumn | RemoveColumn => { + p.rect(2.5, 1.5, 8.5, 14.5, 1.0); + p.line(2.5, 8.0, 8.5, 8.0); + p.line(10.0, 8.0, 15.0, 8.0); + if icon == AddColumn { + p.line(12.5, 5.5, 12.5, 10.5); + } + } Marker => { p.poly(&[(3.0, 13.0), (3.0, 10.5), (10.0, 3.5), (12.5, 6.0), (5.5, 13.0)], true); p.line(8.5, 5.0, 11.0, 7.5); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ccfe6020..bbc39628 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -10,6 +10,7 @@ pub mod assets_panel; pub mod bidi; pub mod board_bar; pub mod board_facilitate; +mod board_picker; pub mod branch_ui; pub mod canvas; pub mod cjk; @@ -72,6 +73,8 @@ pub mod versions; pub mod widgets; pub mod windows; +#[cfg(test)] +mod board_picker_tests; #[cfg(test)] mod board_ui_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index d94ecc14..a5795a8c 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -134,7 +134,7 @@ pub(crate) fn bar_width(app: &FigApp) -> f32 { fn main_width(groups: &[Group], board: bool) -> f32 { let (cell, gap) = (TOOL_CELL, 4.0); let carets = groups.iter().filter(|g| g.has_flyout()).count() as f32 * (FLYOUT_GAP + FLYOUT_W); - let end = if board { cell + 16.0 } else { 4.0 * 34.0 + 24.0 }; + let end = if board { 2.0 * cell + gap + 16.0 } else { 4.0 * 34.0 + 24.0 }; groups.len() as f32 * (cell + gap) + carets + 2.0 * (cell + gap) + 16.0 + end } @@ -157,6 +157,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens, board: x += 8.0; if board { // Boards have no prototypes or Dev Mode; the Design panel is hidden until asked for. + x = crate::board_picker::button(app, ui, pos2(x, cy), rect.min.y, t); let r = Rect::from_center_size(pos2(x + TOOL_CELL / 2.0, cy), vec2(TOOL_CELL, TOOL_CELL)); let tip = crate::shortcuts::tip(tr("Design panel"), "view.design"); if tool_cell(ui, t, r, "board_panel", Icon::Panels, app.ui.board_panel, tip).clicked() { diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 0f5884dd..af42d363 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -370,7 +370,9 @@ fn the_unsaved_changes_question_offers_save_dont_save_and_cancel() { let (ctx, mut app, _) = asking(None, vec![]); ctx.enable_accesskit(); frame(&ctx, &mut app, false); - let out = ctx.run_ui(RawInput::default(), |ui| app.ui(ui)); + let mut out = ctx.run_ui(RawInput::default(), |ui| app.ui(ui)); + // A canvas picture that lands in this frame uploads a texture nobody applies here. + out.textures_delta.clear(); let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; let buttons: Vec = nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Button).filter_map(|(_, n)| n.label().map(str::to_string)).collect(); diff --git a/docs/boards-parity.md b/docs/boards-parity.md index e5f6cd6e..fcf44627 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,13 +1,14 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) -**Breadth ~42%, ready ~25% (estimated, erring low).** A board is a FigCraft file of its own kind +**Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that follow what they join (straight, elbow or curved, their ends dragged onto other objects), marker, -highlighter and eraser, stamps, text and sections, edited together in the existing live sessions, -with a shared countdown and voting sessions to run a workshop. Music, board widgets, templates, -tables, mind maps and the rest of the tail are missing. Remaining: ~24–50 h. Tracked as area 22 of +highlighter and eraser, stamps, text and sections, five built-in templates and tables, edited +together in the existing live sessions, with a shared countdown and voting sessions to run a +workshop. Music, board widgets, mind maps and the rest of the tail are missing. Remaining: +~20–44 h. Tracked as area 22 of [`target-app-parity.md`](target-app-parity.md) (not in its weighted sum: the target there is the design product). The work item is gap 24 in [`gaps.md`](gaps.md). @@ -49,8 +50,8 @@ all end with the same geometry. | Voting sessions | done | one at a time: votes per person (1–99), on every object, on sticky notes or on the selection; a click on an object places a vote (several on one object allowed), a click on your badge or an Alt-click takes it back; your own votes show as badges while it runs, everyone's once it ends, with a ranked list (ties share a rank) that selects an object from its row; Clear results. `board.vote {action: start\|cast\|retract\|end\|clear}`, `board.voteResults`. Ballots sync per voter (keyed by the live-session client id, or your name offline), so people voting at once never overwrite each other. Not undo steps | | Voting: only the facilitator ends or clears it, who voted for what | missing | anyone who can edit the board can end or clear a vote; results are counts only | | Board widgets | missing | design-file widgets exist (`crates/engine/src/widgets.rs`), not wired to boards | -| Templates (retrospective, brainstorm, flow chart…) | missing | | -| Tables | missing | | +| Templates (retrospective, brainstorm, flow chart…) | partial | five built-in templates drawn up for FigCraft (retrospective, brainstorm, weekly planner, flow chart, kanban board), placed from the toolbar's templates picker (each with a picture of itself) or `board.template {name, x?, y?}` as ordinary sections, stickies, shapes, connectors and text in one undo step; `board.templates` lists them. No community templates, no saving your own | +| Tables | partial | `board.table {rows, columns, cells?, x?, y?}` (at most 50 × 20): an auto layout frame of rows of cells with text, a tinted header row and lines between rows and columns; a cell's row grows with its text; a double-click types in the cell under the pointer; add or remove a row or column from the bar over the selection or `board.tableEdit {id, action, index?}`; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells, no CSV paste | | Mind maps | missing | | | Washi tape, code blocks, image cropping on boards | missing | images can be placed as in design files | | Audio and huddles | missing | | @@ -64,4 +65,5 @@ all end with the same geometry. |---|---|---| | 2026-10-24 | minor | M12: the dot grid on board pages (a render pass under the layers), dragging a selected connector's end onto another object or empty canvas (`board.reattach`), curved connectors, and elbows that go around the objects they join. Breadth ~35% → ~37%, ready ~20% → ~21%, ~30–60 h → ~28–56 h | | 2026-10-24 | minor | Facilitation (M12): a shared timer and voting sessions. Breadth ~35% → ~40%, ready ~20% → ~24%, ~30–60 h → ~26–54 h | +| 2026-10-24 | minor | Templates (retrospective, brainstorm, weekly planner, flow chart, kanban board) from a toolbar picker or `board.template`, and tables (`board.table`, `board.tableEdit`): breadth ~35% → ~40%, ready ~20% → ~24%, remaining 30–60 h → 26–54 h | | 2026-10-24 | major | Created with board files (M12): sticky notes, shapes with text, connectors, ink and eraser, stamps, the board toolbar and contextual bar, live-session sync | diff --git a/docs/gaps.md b/docs/gaps.md index 03c0d461..ba4e0294 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-25 · **Change:** minor (gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); 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-25 · **Change:** minor (gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); 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 @@ -37,7 +37,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 21 | Menu items without a command: none left (landed 2026-10-11) | Feature | — | [parity-checklist](parity-checklist.md) | | 22 | Screen readers never run; browser has no accessibility tree | UI/UX | 4–8 | [ui-parity](ui-parity.md) | | 23 | CMYK output intent for print PDFs | File format | 2–4 | [file-format-parity](file-format-parity.md) | -| 24 | Boards: the whiteboard tail (widgets, templates, tables, mind maps…) | Feature | 24–50 | [boards-parity](boards-parity.md) | +| 24 | Boards: the whiteboard tail (widgets, mind maps, waypoints…) | Feature | 20–44 | [boards-parity](boards-parity.md) | ## Entries @@ -477,16 +477,19 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe connectors that re-route when what they join moves, marker, highlighter and eraser, stamps, text and sections; a board toolbar and a contextual bar; live sessions sync it all. Then (also M12) a timer everyone on the board sees and voting sessions with per-person caps, hidden votes and - ranked results (`board.timer`, `board.vote`). + ranked results (`board.timer`, `board.vote`), and five built-in templates (retrospective, + brainstorm, weekly planner, flow chart, kanban board) from a toolbar picker or `board.template`, + and tables (`board.table`, `board.tableEdit`, up to 50 × 20) made of auto layout frames. - **Landed (2026-10-24, M12):** a dot grid on board pages, under the layers; a selected connector's ends drag onto another object or empty canvas (`board.reattach`); curved connectors; elbows that go around the objects they join. -- **Missing:** timer music, board widgets, templates, tables, mind maps, washi tape, code blocks, - audio and huddles, quick-create handles, connector waypoints placed by hand, stamps sticking to - the objects they land on, `.jam` files ([`boards-parity.md`](boards-parity.md)). -- **User impact:** boards cover a brainstorm, a flow sketch and a timed, voted workshop round; - workshops still need templates. -- **Estimate:** 24–50 h. +- **Missing:** timer music, board widgets, saving your own templates, table column resizing and + cell colours, mind maps, washi tape, code blocks, audio and huddles, quick-create handles, + connector waypoints placed by hand, stamps sticking to the objects they land on, `.jam` files + ([`boards-parity.md`](boards-parity.md)). +- **User impact:** boards cover a brainstorm, a retrospective, a flow sketch and a timed, voted + workshop round. +- **Estimate:** 20–44 h. ## Revision history @@ -497,6 +500,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged | | 2026-10-24 | minor | Gap 16: Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | | 2026-10-24 | minor | Gap 24: a shared board timer and voting sessions landed (M12); 30–60 h → 26–54 h, ranking unchanged | +| 2026-10-24 | minor | Gap 24: board templates (retrospective, brainstorm, weekly planner, flow chart, kanban board; `board.template`, a toolbar picker) and tables (`board.table`, `board.tableEdit`); 30–60 h → 26–54 h, rank unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 22ee513d..7f8955a5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -414,6 +414,17 @@ one undo step and returns the ids it made: - `board.setColor {ids?, color}`: stickies and shapes take it as their fill, connectors and ink as their line. `board.setStyle {ids?, shape?, fontSize?, style?: straight|elbow|curved, startCap?, endCap?}`: a shape's outline and text size, a connector's routing and arrowheads. +- `board.templates` → `[{id, name, description, width, height}]` lists the built-in templates: + `retrospective brainstorm weeklyPlanner flowChart kanban`. `board.template {name, x?, y?}` → + `{name, ids}` places one (its top-left at `x`, `y`; centred in the view without them) as ordinary + sections, stickies, shapes, connectors and text, selected, in one undo step. +- `board.table {rows?, columns?, cells?, x?, y?}` → `{id, rows, columns, cells}`: a table (default + 3 × 3, at most 50 × 20; `cells` is `[["Name", "Role"], …]` row by row and sets the size when + `rows`/`columns` are left out). It is an auto layout frame of row frames holding cell frames, each + with one text layer; `cells` in the answer holds those text layers' ids row by row, so + `node.set {id, props: {characters}}` changes a cell. A cell's row grows with its text. `board.tableEdit {id?, action: addRow|addColumn|removeRow|removeColumn, + index?}` → the same answer: `index` is where the new row or column goes, or which one goes + (default the last); `id` may be the table or any layer in it. - Tools for `pointer`: `tool.sticky {color?}`, `tool.shape {shape?}`, `tool.connector {style?: straight|elbow|curved}` (drag from one object to another), `tool.marker {color?}`, `tool.highlighter {color?}`, `tool.eraser` (drag over strokes), `tool.stamp {stamp?}`; Text and Section are the usual tools. diff --git a/docs/roadmap.md b/docs/roadmap.md index 59deea4c..4340d4db 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -60,7 +60,7 @@ blockers. | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | | M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 | | M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | -| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 24–50 | +| M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 20–44 | | M13 AI features (new, owner decision) | not started | local models | 30–60 | ## Next, after the current focus diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 86cf7913..1e9d3a1f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -117,7 +117,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | -| 22 | Boards (whiteboarding) | 0 | ~42% | ~25% | Board files with a dot grid, sticky notes, shapes with text, connectors that follow what they join (straight, elbow around the objects, or curved; ends dragged to re-attach), marker, highlighter and eraser, stamps, text and sections, a shared timer and voting sessions, in live sessions (M12); missing: timer music, board widgets, templates, tables, mind maps, washi tape, audio, quick-create handles, connector waypoints, stamps sticking to objects, `.jam` files ([boards-parity](boards-parity.md)). Not in the weighted sum | 24–50 | +| 22 | Boards (whiteboarding) | 0 | ~47% | ~29% | Board files with a dot grid, sticky notes, shapes with text, connectors that follow what they join (straight, elbow around the objects, or curved; ends dragged to re-attach), marker, highlighter and eraser, stamps, text and sections, five built-in templates and tables, a shared timer and voting sessions, in live sessions (M12); missing: timer music, board widgets, mind maps, washi tape, audio, quick-create handles, connector waypoints, stamps sticking to objects, `.jam` files ([boards-parity](boards-parity.md)). Not in the weighted sum | 20–44 | | | **Weighted (÷113)** | 113 | **~85%** | **~63%** | | **117–255** | | | Core editor only (1–16, ÷94) | 94 | ~94% | ~72% | | 32–74 | @@ -644,6 +644,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th |---|---|---| | 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | | 2026-10-24 | minor | Boards (area 22, weight 0): a shared timer and voting sessions (M12); ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; weighted numbers unchanged | +| 2026-10-24 | minor | Boards (M12): templates (five built in, `board.template`) and tables (`board.table`, `board.tableEdit`); area 22 ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h (outside the weighted sum) | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) | | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | | 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged | From 948358eefacff721fdfadc5d4a2d98c672f3b444 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 26 Oct 2026 13:09:03 +0700 Subject: [PATCH 11/15] Docs: README screenshots of Boards and Dev Mode measurements (#1252) --- README.md | 10 ++++++++++ docs/images/shot-8-board.png | Bin 0 -> 315181 bytes docs/images/shot-9-devmode-measure.png | Bin 0 -> 301516 bytes 3 files changed, 10 insertions(+) create mode 100644 docs/images/shot-8-board.png create mode 100644 docs/images/shot-9-devmode-measure.png diff --git a/README.md b/README.md index 778f558f..2e91506a 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,16 @@

Live sessions: three apps on one figcraft-server file, with cursors, selections, cursor chat and spotlight

+ + + A board file named Sprint retro on a dot grid: a Sprint 14 retro made from the retrospective template, with Went well, To improve and Next steps sections of sticky notes, vote counts of 3, 2, 1 and 1 on four stickies after a vote has ended, and below it a pull request flow chart from the flow chart template whose shapes are joined by straight and elbow connectors with Yes and No labels; a timer pill in the top right shows 4:30 left with pause, add a minute, stop and Results buttons +

Boards: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions

+ + + Dev Mode on the Ridgeline home screen with the featured trail card selected: red redlines kept on the canvas show 118.3 from the card to the search field, 62.1 from the card to the first trail row and 256.3 along the card's left side, and the Inspect panel's Measurements section lists the three with their values under an Add measurement menu +

Dev Mode measurements: redlines placed between layers or along a side, saved in the file and following the layers · examples/ridgeline-app.figcraft

+ + ## Made in FigCraft diff --git a/docs/images/shot-8-board.png b/docs/images/shot-8-board.png new file mode 100644 index 0000000000000000000000000000000000000000..ef0b9d49062b3ee5dabcc3a50452a645850badcb GIT binary patch literal 315181 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%0DNqnO?fLvhp%KFpAHu=AGaDJWzx9tg-qVAE0la? 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�S160F%dL~eMzj0xz1E7ggvTPN$>gc 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
?+Ns^%Y3I+D7W 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�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 NxMu?Pj-9#e{{Zo&w&VZ+ literal 0 HcmV?d00001 diff --git a/docs/images/shot-9-devmode-measure.png b/docs/images/shot-9-devmode-measure.png new file mode 100644 index 0000000000000000000000000000000000000000..8dde99d7a63d0a8184e91359bd34f891df87d526 GIT binary patch literal 301516 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

dlYty931J4R3#g0}RH=1)i>@hl`m=$(&X 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�gSNp!+A|e#|ftab0>q_XQzRA)7z^gG2tTY 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 literal 0 HcmV?d00001 From 8d60360173ca19fbbf44772824f82ad499be5b53 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 26 Oct 2026 13:31:53 +0700 Subject: [PATCH 12/15] M11: widgets take text input and a property menu (plugin API 4) (#1365) * M8: widgets take text input and a property menu Plugin API 4 (gap 6). A widget's text layer can be an editable field: a double-click edits it like any text, and when editing ends the value (cleaned, at most 1,000 characters) goes into the widget's state under the field's name and its plugin redraws, as one undo step. A widget can also carry a property menu of up to 8 actions, toggles and colour choices, shown in a small bar over it while it's selected. - widget.setText and widget.menu drive the same paths from scripts, the CLI and MCP; widget.insert/list also report fields and menu - descriptions, values and stored menus are validated as untrusted; an API 3 plugin drawing a field or menu is refused, older hosts refuse API 4 manifests; no new host imports - SDK reads the new events; the Counter example gains a label field and a menu (reset, steps of ten, a colour) - tests for fields, menus, canvas editing, rollback, forged data and live sync; junk sweep extended; labels in every command catalog - docs: plugins, mcp, gaps, target-app-parity, ROADMAP, roadmap * M8: widget property menu on egui 0.37 Id::new and Ui::id are deprecated in egui 0.37: the widget bar's area uses Id::unique and its buttons' interaction ids come from ui.scope_id(). * M11: a double-click a board object or a widget field takes, in a function of its own (Sonar) --- ROADMAP.md | 17 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests.rs | 3 + crates/engine/src/textedit.rs | 15 ++ crates/engine/src/tools.rs | 15 +- crates/engine/src/widget_input_tests.rs | 211 ++++++++++++++++++ crates/engine/src/widgets.rs | 193 +++++++++++++--- crates/engine/src/widgets_tests.rs | 18 +- crates/plugin/src/lib.rs | 2 +- crates/plugin/src/manifest.rs | 2 +- crates/plugin/src/widget.rs | 116 +++++++++- crates/plugin/src/widget_tests.rs | 48 +++- crates/ui-egui/locales/bn.toml | 2 + crates/ui-egui/locales/cs.toml | 2 + crates/ui-egui/locales/de.toml | 2 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/locales/fi.toml | 2 + crates/ui-egui/locales/fr.toml | 2 + crates/ui-egui/locales/id.toml | 2 + crates/ui-egui/locales/ja.toml | 2 + crates/ui-egui/locales/ka.toml | 2 + crates/ui-egui/locales/ko.toml | 2 + crates/ui-egui/locales/nl.toml | 2 + crates/ui-egui/locales/pl.toml | 2 + crates/ui-egui/locales/pt-BR.toml | 2 + crates/ui-egui/locales/ru.toml | 2 + crates/ui-egui/locales/sv.toml | 2 + crates/ui-egui/locales/tr.toml | 2 + crates/ui-egui/locales/zh-Hans.toml | 2 + crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/widget_bar.rs | 113 ++++++++++ docs/gaps.md | 18 +- docs/mcp.md | 13 +- docs/plugins.md | 75 +++++-- docs/roadmap.md | 5 +- docs/target-app-parity.md | 18 +- plugins/examples/counter/figcraft-plugin.json | 6 +- plugins/examples/counter/src/lib.rs | 120 +++++++--- plugins/sdk/src/lib.rs | 36 ++- 40 files changed, 959 insertions(+), 123 deletions(-) create mode 100644 crates/engine/src/widget_input_tests.rs create mode 100644 crates/ui-egui/src/widget_bar.rs diff --git a/ROADMAP.md b/ROADMAP.md index 50a3446b..d7cd3c10 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-25 · **Change:** minor (boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -19,13 +19,13 @@ start with [`docs/gaps.md`](docs/gaps.md). | Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | | Remaining to beta | **~155–280 h** one agent | estimated | -| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | +| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) | | Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | @@ -65,7 +65,7 @@ native speakers' reviews of the nine language drafts need people. | Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) | | Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | -| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) | +| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | ## Features @@ -89,7 +89,7 @@ native speakers' reviews of the nine language drafts need people. | Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | -| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | +| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) | | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -139,6 +139,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged. +- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run + unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes + into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget + can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected + (`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets + 65%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. @@ -348,6 +354,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | +| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 44502f4f..64740d14 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1964,6 +1964,8 @@ mod version_tests; #[cfg(test)] mod view_behaviour_tests; #[cfg(test)] +mod widget_input_tests; +#[cfg(test)] mod widgets_tests; #[cfg(test)] diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ac05185f..811724f2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -415,6 +415,9 @@ fn every_command_survives_junk_params() { json!({"from": 2, "to": 1, "side": "auto"}), // Widgets: plugins, targets and fuel as junk. json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}), + // Widget fields and menus: fields, items and values as junk. + json!({"id": 1, "field": {"x": 1}, "item": [1], "value": {"x": "\u{0}"}, "fuel": 1e308}), + json!({"id": [1], "field": "label\u{0}", "item": "", "value": "\u{7}".repeat(5000)}), // Plugin directories: ids, versions, servers and confirmations as junk. json!({"id": "../../x", "version": {"x": 1}, "server": "ws://\u{0}/?key=", "confirm": "yes", "path": [5], "plugin": [1], "query": 7, "sha256": 3}), json!({"id": "org.example.x", "version": "1.0.0", "server": "wss://[::1", "confirm": true, "plugin": "nope"}), diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index e236e322..2cd9bf97 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -110,6 +110,21 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - pub fn end(s: &mut Session) -> Result<()> { let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) }; s.ds_mut()?.text_edit = None; + // A widget's editable field: the typing is put back and the new value goes to the widget's + // plugin through `widget.setText`, one undo step (or none when nothing changed). + if let Some((w, field)) = s.doc().ok().and_then(|d| crate::widgets::field_of(d, te.id)) { + let value = text_of(s, te.id).map(|t| t.characters).unwrap_or_default(); + s.cancel_interaction()?; + let ds = s.ds_mut()?; + ds.selection = vec![w]; + ds.revision += 1; + if text_of(s, te.id).is_ok_and(|t| t.characters != value) + && let Err(e) = s.execute("widget.setText", &serde_json::json!({"id": w, "field": field, "value": value})) + { + s.message = Some(e.to_string()); + } + return Ok(()); + } // A board object's text (a sticky's note) stays when emptied; the object is selected again. let owner = s.doc().ok().and_then(|d| crate::board::owner_of_text(d, te.id)); let empty = owner.is_none() && text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 3e2dcd79..629c9a06 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2105,6 +2105,19 @@ impl Session { /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text /// editing (select-all on entry, word-select inside the running edit), enter vector editing /// for selected shapes, and step groups/frames one level deeper. + /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into + /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing + /// ends). Whether it was taken. + fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { + return Ok(true); + } + match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { + Some(field) => textedit::begin(self, field, None, false).map(|()| true), + None => Ok(false), + } + } + fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); @@ -2157,7 +2170,7 @@ impl Session { return crate::vectoredit::begin_many(self, &[id]); } let Some(leaf) = hit_here else { return Ok(()) }; - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(&doc, leaf, &sel)? { + if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { return Ok(()); } let target = select_target(&doc, leaf, &sel, true); diff --git a/crates/engine/src/widget_input_tests.rs b/crates/engine/src/widget_input_tests.rs new file mode 100644 index 00000000..ba202987 --- /dev/null +++ b/crates/engine/src/widget_input_tests.rs @@ -0,0 +1,211 @@ +//! Widget fields and property menus (plugin API 4): `widget.setText` and `widget.menu` call the +//! plugin back as one undo step each, from commands and from the canvas (a double-click edits a +//! field like any text); values, items and descriptions from plugins and files are checked, and +//! junk is refused and rolled back. The test plugin outputs its input; on a field edit it draws +//! nothing (the host keeps the value), on `reset` it redraws without a menu. + +use serde_json::{Value, json}; + +use crate::components::tests::{ex, idv, node}; +use crate::plugins_tests::{install, undo_len}; +use crate::tools::{PointerEvent, PointerKind}; +use crate::widgets_tests::{W, err, state}; +use crate::{Session, textedit}; +use figcraft_doc::NodeId; + +const ID: &str = "org.test.note"; + +fn manifest(id: &str, api: u32) -> Value { + json!({"id": id, "name": "Note", "version": "1.0.0", "main": "n.wasm", "api": api, "widget": true, "permissions": {"access": "read"}}) +} + +/// The note widget: a label field, a clickable square and (unless `bare`) a menu. +fn look(bare: bool) -> Value { + let root = json!({"type": "frame", "name": "Note", "layout": "vertical", "gap": 8, "padding": 10, "fill": "#FFFFFF", "children": [ + {"type": "text", "name": "Label", "field": "label", "text": "Label", "size": 14, "width": 120, "color": "#111111"}, + {"type": "rectangle", "target": "inc", "width": 20, "height": 20, "fill": "#0D99FF"}, + ]}); + if bare { + return json!({"state": {"n": 0}, "root": root}); + } + json!({"root": root, "menu": [ + {"item": "reset"}, + {"item": "big", "label": "Big", "type": "toggle", "checked": true}, + {"item": "color", "label": "Colour", "type": "color", "options": ["#ff0000", "#00FF00"], "value": "#FF0000"}, + ]}) +} + +/// The note plugin's module; `on_field` runs on a field edit (default: return without drawing). +fn note(on_field: &str) -> Vec { + let mut w = W::default(); + let (field, reset) = (w.has(r#""field":"label""#), w.has(r#""menu":"reset""#)); + let (bare, full) = (w.render(&look(true)), w.render(&look(false))); + let on_field = if on_field.is_empty() { "(return (i32.const 0))" } else { on_field }; + w.build(&format!("(if {field} (then {on_field})) (if {reset} (then {bare} (return (i32.const 0)))) {full}")) +} + +fn session() -> (Session, NodeId) { + let mut s = Session::new(); + install(&mut s, manifest(ID, 4), ¬e("")); + let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID, "x": 40, "y": 40}))); + (s, w) +} + +/// The text of widget `w`'s label field. +fn label(s: &Session, w: NodeId) -> String { + let first = node(s, w).children[0]; + node(s, first).kind.text().unwrap().characters.clone() +} + +#[test] +fn a_field_edit_reaches_the_state_the_layer_and_the_plugin_as_one_undo_step() { + let (mut s, w) = session(); + let list = ex(&mut s, "widget.list", json!({})); + let item = &list["widgets"][0]; + assert_eq!((item["fields"].clone(), item["targets"].clone()), (json!(["label"]), json!(["inc"]))); + assert_eq!(item["menu"][0], json!({"item": "reset", "label": "reset", "type": "action"}), "a label defaults to the item's name"); + assert_eq!(item["menu"][2]["options"], json!(["#FF0000", "#00FF00"]), "colours are normalised"); + let undo = undo_len(&s); + // Tabs become spaces and other control characters are dropped. + let inner = node(&s, w).children[1]; + let r = ex(&mut s, "widget.setText", json!({"id": inner, "field": "label", "value": "Hello\tworld\u{7}"})); + assert_eq!(r["output"]["widget"]["event"], json!({"field": "label", "value": "Hello world"})); + assert_eq!(r["output"]["widget"]["state"], json!({"label": "Hello world"}), "the plugin sees the value in the state too"); + assert_eq!((state(&mut s, w), label(&s, w), undo_len(&s)), (json!({"label": "Hello world"}), "Hello world".into(), undo + 1)); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((state(&mut s, w), label(&s, w)), (json!({}), "Label".into())); + // Bad edits are refused and change nothing. + for (p, why) in [ + (json!({"id": w, "field": "nope", "value": "x"}), "it has: label"), + (json!({"id": w, "field": "label"}), "`value` required"), + (json!({"id": w, "field": "label", "value": 5}), "`value` required"), + (json!({"id": w, "field": "label", "value": "x".repeat(1001)}), "longer than 1000"), + (json!({"id": w, "value": "x"}), "`field` required"), + (json!({"id": 999_999, "field": "label", "value": "x"}), "isn't a widget"), + ] { + assert!(err(&mut s, "widget.setText", p.clone()).contains(why), "{p}"); + } + assert_eq!((state(&mut s, w), undo_len(&s)), (json!({}), undo)); + assert_eq!( + ex(&mut s, "widget.setText", json!({"id": w, "field": "label", "value": "x".repeat(1000)}))["state"]["label"].as_str().map(str::len), + Some(1000) + ); +} + +#[test] +fn a_runaway_or_failing_field_callback_rolls_the_value_back() { + let mut s = Session::new(); + install(&mut s, manifest(ID, 4), ¬e("(loop $l (br $l))")); + let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID}))); + let undo = undo_len(&s); + assert!(err(&mut s, "widget.setText", json!({"id": w, "field": "label", "value": "Hi", "fuel": 100_000})).contains("instruction budget")); + assert_eq!((state(&mut s, w), label(&s, w), undo_len(&s)), (json!({}), "Label".into(), undo)); + // A plugin that returns an error. + install(&mut s, manifest(ID, 4), ¬e("(return (i32.const 1))")); + assert!(s.execute("widget.setText", &json!({"id": w, "field": "label", "value": "Hi"})).is_err()); + assert_eq!((state(&mut s, w), undo_len(&s)), (json!({}), undo)); +} + +#[test] +fn menu_items_call_the_plugin_back_with_their_values() { + let (mut s, w) = session(); + let event = |s: &mut Session, p: Value| ex(s, "widget.menu", p)["output"]["widget"]["event"].clone(); + // A toggle flips by default, or takes the value given; a colour is matched in any case. + assert_eq!(event(&mut s, json!({"id": w, "item": "big"})), json!({"menu": "big", "value": false})); + assert_eq!(event(&mut s, json!({"id": w, "item": "big", "value": true})), json!({"menu": "big", "value": true})); + assert_eq!(event(&mut s, json!({"id": w, "item": "color", "value": "#00ff00"})), json!({"menu": "color", "value": "#00FF00"})); + let undo = undo_len(&s); + for (p, why) in [ + (json!({"id": w, "item": "color", "value": "#0000FF"}), "must be one of #FF0000, #00FF00"), + (json!({"id": w, "item": "color"}), "must be one of"), + (json!({"id": w, "item": "color", "value": "#".repeat(5000)}), "must be one of"), + (json!({"id": w, "item": "nope"}), "it has: reset, big, color"), + (json!({"id": w}), "`item` required"), + ] { + assert!(err(&mut s, "widget.menu", p.clone()).contains(why), "{p}"); + } + assert_eq!(undo_len(&s), undo); + // An action; this one redraws without a menu, which then has nothing to choose. + let r = ex(&mut s, "widget.menu", json!({"id": w, "item": "reset"})); + assert_eq!( + (r["output"]["widget"]["event"].clone(), r["state"].clone(), r["menu"].clone()), + (json!({"menu": "reset", "value": null}), json!({"n": 0}), json!([])) + ); + assert_eq!(undo_len(&s), undo + 1); + assert!(err(&mut s, "widget.menu", json!({"id": w, "item": "reset"})).contains("no menu item")); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(crate::widgets::menu_of(s.doc().unwrap(), w).len(), 3, "undo brings the menu back"); +} + +#[test] +fn fields_and_menus_need_api_4_and_forged_menus_are_ignored() { + let mut s = Session::new(); + install(&mut s, manifest("org.test.old", 3), ¬e("")); + assert!(err(&mut s, "widget.insert", json!({"plugin": "org.test.old"})).contains("need plugin API 4")); + assert!(ex(&mut s, "widget.list", json!({}))["widgets"].as_array().unwrap().is_empty(), "rolled back"); + // A file (or a peer) can carry anything in a widget's menu data: junk reads as no menu. + let (mut s, w) = session(); + let deep = "[".repeat(100_000); + for junk in ["not json", r#"[{"item": "has space"}]"#, r#"[{"item": "c", "type": "color"}]"#, "{}", deep.as_str()] { + s.ds_mut().unwrap().doc_mut().get_mut(w).unwrap().plugin_data.insert("figcraft.widget.menu".into(), junk.to_string()); + assert!(crate::widgets::menu_of(s.doc().unwrap(), w).is_empty(), "{}", &junk[..junk.len().min(40)]); + assert!(err(&mut s, "widget.menu", json!({"id": w, "item": "c"})).contains("no menu item")); + } +} + +#[test] +fn double_clicking_a_field_edits_it_on_the_canvas() { + let (mut s, w) = session(); + let field = node(&s, w).children[0]; + // Without any font a text may have no size to click on. + let Some(b) = s.doc().unwrap().world_bounds(field).filter(|b| b.width() > 1.0 && b.height() > 1.0) else { return }; + let p = b.center(); + let at = |kind| PointerEvent::at(kind, p.x, p.y, 1.0); + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::Up)).unwrap(); + assert_eq!(s.selection(), vec![w], "a click selects the widget"); + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::DoubleClick)).unwrap(); + assert_eq!(s.ds().unwrap().text_edit.as_ref().map(|te| te.id), Some(field), "a double-click edits the field"); + let undo = undo_len(&s); + // Ending without a change makes no undo step and doesn't call the plugin. + textedit::end(&mut s).unwrap(); + assert_eq!((undo_len(&s), state(&mut s, w), s.selection()), (undo, json!({}), vec![w])); + // Typing replaces the text (it's all selected); ending sends it to the plugin, one undo step. + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::DoubleClick)).unwrap(); + textedit::insert(&mut s, "Hi").unwrap(); + ex(&mut s, "text.edit.end", json!({})); + assert_eq!((state(&mut s, w), label(&s, w), undo_len(&s)), (json!({"label": "Hi"}), "Hi".into(), undo + 1)); + assert_eq!((s.selection(), s.ds().unwrap().text_edit.is_none()), (vec![w], true)); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(label(&s, w), "Label"); + // A failed callback leaves the field as it was and says why. + ex(&mut s, "plugin.remove", json!({"id": ID})); + s.pointer(&at(PointerKind::Down)).unwrap(); + s.pointer(&at(PointerKind::DoubleClick)).unwrap(); + textedit::insert(&mut s, "Lost").unwrap(); + textedit::end(&mut s).unwrap(); + assert_eq!(label(&s, w), "Label"); + assert!(s.message.as_deref().is_some_and(|m| m.contains("no plugin")), "{:?}", s.message); +} + +#[test] +fn a_field_edit_syncs_in_a_live_session() { + use crate::live_tests::{push, relay, serve_as, session as live}; + let (mut s, script) = live(); + install(&mut s, manifest(ID, 4), ¬e("")); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, me, welcome) = serve_as(s.doc().unwrap(), crate::live::Access::Edit); + push(&script, vec![welcome]); + s.live_poll(); + let w = idv(&ex(&mut s, "widget.insert", json!({"plugin": ID}))); + ex(&mut s, "widget.setText", json!({"id": w, "field": "label", "value": "Shared"})); + s.live_sync(); + let back = relay(&script, &mut server, me); + push(&script, back); + s.live_poll(); + let theirs = server.document().node(w).unwrap(); + assert_eq!(theirs.plugin_data.get("figcraft.widget.state").map(String::as_str), Some(r#"{"label":"Shared"}"#)); + assert!(theirs.plugin_data.contains_key("figcraft.widget.menu"), "the menu travels with the widget"); +} diff --git a/crates/engine/src/widgets.rs b/crates/engine/src/widgets.rs index c1ccd2a3..5d824363 100644 --- a/crates/engine/src/widgets.rs +++ b/crates/engine/src/widgets.rs @@ -6,13 +6,18 @@ //! of it the plugin named as a target calls the plugin back with that state, in a fresh sandbox //! (deterministic: the state is all it remembers), as one undo step; the plugin hands back the new //! state and layers. A callback can only rebuild its own widget; other layers change only through -//! the commands its manifest permits. See `docs/plugins.md`. +//! the commands its manifest permits. Since API 4 a text layer can be an editable field (edited on +//! the canvas like any text, or with `widget.setText`: the value goes into the state under the +//! field's name and the plugin is called back), and a widget can have a small property menu +//! (actions, toggles, a colour from a few) shown while it's selected (`widget.menu`). See +//! `docs/plugins.md`. use figcraft_color::{ImageScaleMode, Paint}; use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData}; use figcraft_geom::{Affine, Point}; -use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES}; +use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES, check_menu, check_state, clean_field}; use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender}; +pub use figcraft_plugin::{MenuItem, MenuKind}; use serde_json::{Map, Value, json}; use crate::cmd::CommandSpec; @@ -25,8 +30,12 @@ pub const DATA_PREFIX: &str = "figcraft.widget"; /// On a widget's own layer: its plugin's id, and its state (JSON). const PLUGIN: &str = "figcraft.widget"; const STATE: &str = "figcraft.widget.state"; +/// On a widget's own layer: its property menu (JSON). +const MENU: &str = "figcraft.widget.menu"; /// On a layer inside a widget: the name its plugin gets when it's clicked. const TARGET: &str = "figcraft.widget.target"; +/// On a text layer inside a widget: the name of the editable field it is. +const FIELD: &str = "figcraft.widget.field"; /// Images in a widget are at most this many pixels on a side. const MAX_IMAGE_SIDE: u32 = 4096; @@ -40,18 +49,44 @@ pub fn widget_of(doc: &Document, id: NodeId) -> Option { std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(is_widget)) } -/// The clickable part of a widget at page point `p`, given the layer a click there hits: the widget -/// and its target. The layers inside a widget are locked, so the hit stops at the widget itself; -/// the front-most target whose box holds the point wins. -pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> { +/// The part of a widget at page point `p` marked with layer data `key`, given the layer a click +/// there hits: the widget, the part and its name. The layers inside a widget are locked, so the +/// hit stops at the widget itself; the front-most part whose box holds the point wins. +fn part_at(doc: &Document, hit: NodeId, p: Point, key: &str) -> Option<(NodeId, NodeId, String)> { let w = widget_of(doc, hit)?; - let target = doc - .subtree(w) + doc.subtree(w) .into_iter() .filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p))) - .filter_map(|i| doc.node(i).filter(|n| n.visible)?.plugin_data.get(TARGET).cloned()) - .last()?; - Some((w, target)) + .filter_map(|i| Some((w, i, doc.node(i).filter(|n| n.visible)?.plugin_data.get(key).cloned()?))) + .last() +} + +/// The clickable part of a widget at page point `p`, given the layer a click there hits: the widget +/// and its target. +pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> { + part_at(doc, hit, p, TARGET).map(|(w, _, t)| (w, t)) +} + +/// The editable text field of a widget at page point `p`, given the layer a click there hits: its +/// text layer. +pub fn field_at(doc: &Document, hit: NodeId, p: Point) -> Option { + part_at(doc, hit, p, FIELD).map(|(_, f, _)| f).filter(|f| field_of(doc, *f).is_some()) +} + +/// The widget and field name text layer `id` is the editable field of. +pub fn field_of(doc: &Document, id: NodeId) -> Option<(NodeId, String)> { + let field = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)))?.plugin_data.get(FIELD)?.clone(); + Some((widget_of(doc, id)?, field)) +} + +/// Widget `w`'s property menu (empty when it has none or it's unusable: it may come from a file). +pub fn menu_of(doc: &Document, w: NodeId) -> Vec { + doc.node(w) + .and_then(|n| n.plugin_data.get(MENU)) + .filter(|s| s.len() <= MAX_STATE_BYTES) + .and_then(|s| serde_json::from_str::>(s).ok()) + .and_then(|mut m| check_menu(&mut m).is_ok().then_some(m)) + .unwrap_or_default() } /// A widget's state (an empty object when it has none or it's unusable). @@ -67,10 +102,11 @@ fn state_of(doc: &Document, w: NodeId) -> Map { .unwrap_or_default() } -/// The targets inside widget `w`, each once, in layer order. -fn targets(doc: &Document, w: NodeId) -> Vec { +/// The names of the parts inside widget `w` marked with layer data `key` (its targets or its +/// fields), each once, in layer order. +fn names(doc: &Document, w: NodeId, key: &str) -> Vec { let mut out: Vec = Vec::new(); - for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(TARGET).cloned()) { + for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(key).cloned()) { if !out.contains(&t) { out.push(t); } @@ -89,20 +125,22 @@ fn widget_plugin(s: &Session, cmd: &str, plugin: &str) -> Result, fuel: Option) -> Result { +/// Call widget `w`'s plugin `p` back (`event`: what happened, a click, an edit or a menu item; +/// none when it's inserted) and build what it hands back. Part of the caller's undo step; a failure +/// rolls the whole step back. +fn callback(s: &mut Session, p: &InstalledPlugin, w: NodeId, event: Option, fuel: Option) -> Result { let m = &p.manifest; let wasm = module_of(s, p)?; - let event = target.map(|t| json!({"target": t})); + let inserting = event.is_none(); let state = state_of(s.doc()?, w); let input = json!({"api": m.api, "command": "widget", "params": {}, "selection": s.selection(), "widget": {"id": w, "state": state, "event": event}}); let mut instance = Instance::new(&wasm, &limits(fuel)).map_err(|e| plugin_error(&m.name, e))?; let mut out = call_plugin(s, m, &m.name, &mut instance, &input.to_string()).map_err(|(e, _)| e)?; match out.widget.take() { + Some(r) if m.api < 4 && r.uses_api_4() => return Err(bad("widget.render", format!("{}: fields and menus need plugin API 4", m.name))), Some(r) => build(s, &m.id, w, &r)?, - None if target.is_none() => return Err(bad("widget.insert", format!("{} drew no widget", m.name))), + None if inserting => return Err(bad("widget.insert", format!("{} drew no widget", m.name))), None => {} } Ok(out) @@ -130,6 +168,10 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<( if let Some(state) = &r.state { n.plugin_data.insert(STATE.into(), Value::Object(state.clone()).to_string()); } + match serde_json::to_string(&r.menu) { + Ok(menu) if !r.menu.is_empty() => n.plugin_data.insert(MENU.into(), menu), + _ => n.plugin_data.remove(MENU), + }; for c in &r.root.children { add(doc, w, c, cmd)?; } @@ -154,6 +196,9 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result< if !d.target.is_empty() { n.plugin_data.insert(TARGET.into(), d.target.clone()); } + if !d.field.is_empty() { + n.plugin_data.insert(FIELD.into(), d.field.clone()); + } if d.kind == WidgetKind::Image { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD.decode(d.data.trim()).map_err(|e| bad(cmd, format!("an image's `data`: {e}")))?; @@ -230,12 +275,13 @@ fn props(d: &WidgetNode, root: bool) -> Map { p } -/// What `widget.insert` and `widget.click` return. +/// What `widget.insert` and the callbacks return. fn report(s: &Session, w: NodeId, m: &Manifest, out: RunOutput) -> Result { let doc = s.doc()?; let output = serde_json::from_str::(&out.output).unwrap_or(Value::String(out.output)); + let (targets, fields, menu) = (names(doc, w, TARGET), names(doc, w, FIELD), menu_of(doc, w)); Ok( - json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets(doc, w), "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}), + json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets, "fields": fields, "menu": menu, "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}), ) } @@ -272,21 +318,87 @@ fn insert(s: &mut Session, p: &Value) -> Result { report(s, w, m, out) } +/// The widget param `id` is, or is inside. +fn widget_param(s: &Session, cmd: &str, p: &Value) -> Result { + let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?; + widget_of(s.doc()?, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one"))) +} + +/// Call widget `w`'s plugin back with `ev`, after `prep` (which may change the widget first), as +/// one undo step. +fn event(s: &mut Session, cmd: &str, w: NodeId, ev: Value, p: &Value, prep: impl FnOnce(&mut Session) -> Result<()>) -> Result { + let plugin = s.doc()?.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default(); + let wp = widget_plugin(s, cmd, &plugin)?; + let out = s.grouped(&wp.manifest.name, |s| { + prep(s)?; + callback(s, &wp, w, Some(ev), num(p, "fuel")) + })?; + report(s, w, &wp.manifest, out) +} + fn click(s: &mut Session, p: &Value) -> Result { let cmd = "widget.click"; - let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?; + let w = widget_param(s, cmd, p)?; let target = string(p, "target").ok_or_else(|| bad(cmd, "`target` required (see widget.list)"))?; - let doc = s.doc()?; - let w = widget_of(doc, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one")))?; - let all = targets(doc, w); + let all = names(s.doc()?, w, TARGET); if !all.contains(&target) { return Err(bad(cmd, format!("the widget has no `{target}` to click (it has: {})", all.join(", ")))); } - let plugin = doc.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default(); - let fuel = num(p, "fuel"); - let wp = widget_plugin(s, cmd, &plugin)?; - let out = s.grouped(&wp.manifest.name, |s| callback(s, &wp, w, Some(&target), fuel))?; - report(s, w, &wp.manifest, out) + event(s, cmd, w, json!({"target": target}), p, |_| Ok(())) +} + +fn set_text(s: &mut Session, p: &Value) -> Result { + let cmd = "widget.setText"; + let w = widget_param(s, cmd, p)?; + let field = string(p, "field").ok_or_else(|| bad(cmd, "`field` required (see widget.list)"))?; + let all = names(s.doc()?, w, FIELD); + if !all.contains(&field) { + return Err(bad(cmd, format!("the widget has no field `{field}` (it has: {})", all.join(", ")))); + } + let value = match p.get("value") { + Some(Value::String(v)) => clean_field(v).map_err(|e| bad(cmd, e))?, + _ => return Err(bad(cmd, "`value` required (text)")), + }; + let ev = json!({"field": field, "value": value}); + // The value goes into the state and the field's text before the plugin hears of it, so a + // plugin that doesn't redraw still keeps (and shows) it. + event(s, cmd, w, ev, p, |s| { + let ds = s.ds_mut()?; + let before = ds.doc.clone(); + let mut state = state_of(&ds.doc, w); + state.insert(field.clone(), Value::String(value.clone())); + check_state(&state).map_err(|e| bad(cmd, e))?; + let doc = ds.doc_mut(); + let text = Map::from_iter([("characters".to_string(), Value::String(value))]); + for l in doc.subtree(w) { + if doc.node(l).is_some_and(|n| n.plugin_data.get(FIELD) == Some(&field) && matches!(n.kind, NodeKind::Text(_))) { + crate::cmd::props::apply(cmd, doc, l, &text)?; + } + } + doc.get_mut(w)?.plugin_data.insert(STATE.into(), Value::Object(state).to_string()); + crate::normalize(doc, &before); + Ok(()) + }) +} + +fn menu(s: &mut Session, p: &Value) -> Result { + let cmd = "widget.menu"; + let w = widget_param(s, cmd, p)?; + let item = string(p, "item").ok_or_else(|| bad(cmd, "`item` required (see widget.list)"))?; + let all = menu_of(s.doc()?, w); + let Some(m) = all.iter().find(|m| m.item == item) else { + let names: Vec<&str> = all.iter().map(|m| m.item.as_str()).collect(); + return Err(bad(cmd, format!("the widget has no menu item `{item}` (it has: {})", names.join(", ")))); + }; + let value = match m.kind { + MenuKind::Action => Value::Null, + MenuKind::Toggle => json!(p.get("value").and_then(Value::as_bool).unwrap_or(!m.checked)), + MenuKind::Color => { + let hex = string(p, "value").filter(|v| v.len() <= 9).and_then(|v| figcraft_color::Color::from_hex(&v)).map(|c| c.to_css_hex()); + json!(hex.filter(|h| m.options.contains(h)).ok_or_else(|| bad(cmd, format!("`value` must be one of {}", m.options.join(", "))))?) + } + }; + event(s, cmd, w, json!({"menu": item, "value": value}), p, |_| Ok(())) } /// Click `target` of the widget `id` is (or is in) on `doc`, a document that isn't one of the @@ -311,7 +423,10 @@ fn list(s: &mut Session, _p: &Value) -> Result { .filter_map(|i| { let n = doc.node(i).filter(|n| is_widget(n))?; let state = Value::Object(state_of(doc, i)); - Some(json!({"id": i, "plugin": n.plugin_data.get(PLUGIN), "name": n.name, "page": doc.page_of(i), "state": state, "targets": targets(doc, i)})) + let (targets, fields, menu) = (names(doc, i, TARGET), names(doc, i, FIELD), menu_of(doc, i)); + Some( + json!({"id": i, "plugin": n.plugin_data.get(PLUGIN), "name": n.name, "page": doc.page_of(i), "state": state, "targets": targets, "fields": fields, "menu": menu}), + ) }) .collect(); Ok(json!({"widgets": widgets})) @@ -333,6 +448,20 @@ pub fn specs() -> Vec { false, click ), - spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets}]}", false, list), + spec!( + "widget.setText", + "Edit widget field", + "{id (a widget or a layer in one), field (see widget.list), value (text, up to 1000 characters), fuel?} sets an editable field as typing in it would: the value goes into the state under the field's name and the plugin is called back; one undo step → like widget.insert", + false, + set_text + ), + spec!( + "widget.menu", + "Widget menu", + "{id (a widget or a layer in one), item (see widget.list), value? (a toggle: on or off, default the opposite of now; a colour: one of its options), fuel?} picks an item of the widget's property menu: the plugin is called back; one undo step → like widget.insert", + false, + menu + ), + spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets, fields, menu}]}", false, list), ] } diff --git a/crates/engine/src/widgets_tests.rs b/crates/engine/src/widgets_tests.rs index f228d35c..bc952acd 100644 --- a/crates/engine/src/widgets_tests.rs +++ b/crates/engine/src/widgets_tests.rs @@ -17,13 +17,13 @@ const REPLY: usize = 200_000; /// Lays out a test plugin's strings and builds its module. #[derive(Default)] -struct W { +pub(crate) struct W { data: String, at: usize, } impl W { - fn s(&mut self, text: &str) -> (usize, usize) { + pub(crate) fn s(&mut self, text: &str) -> (usize, usize) { let a = 1000 + self.at; self.data += &format!("(data (i32.const {a}) \"{}\")\n", lit(text)); self.at += text.len(); @@ -32,7 +32,7 @@ impl W { } /// Draw `v`; when it's refused, output why and fail. - fn render(&mut self, v: &Value) -> String { + pub(crate) fn render(&mut self, v: &Value) -> String { let (a, l) = self.s(&v.to_string()); format!( "(if (call $render (i32.const {a}) (i32.const {l})) (then (call $set_output (i32.const {REPLY}) (call $reply_read (i32.const {REPLY}) (i32.const 50000))) (return (i32.const 1))))" @@ -40,13 +40,13 @@ impl W { } /// Whether the input contains `needle`. - fn has(&mut self, needle: &str) -> String { + pub(crate) fn has(&mut self, needle: &str) -> String { let (a, l) = self.s(needle); format!("(call $has (i32.const {a}) (i32.const {l}) (local.get $n))") } /// Call `cmd`; a refusal becomes the output and the run stops (successfully). - fn call(&mut self, cmd: &str, params: &Value) -> String { + pub(crate) fn call(&mut self, cmd: &str, params: &Value) -> String { let ((a, l), (b, m)) = (self.s(cmd), self.s(¶ms.to_string())); format!( "(if (call $call (i32.const {a}) (i32.const {l}) (i32.const {b}) (i32.const {m})) @@ -55,7 +55,7 @@ impl W { } /// The module: it reads and outputs its input, then runs `body`. - fn build(self, body: &str) -> Vec { + pub(crate) fn build(self, body: &str) -> Vec { let wat = format!( r#"(module (import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32))) @@ -108,7 +108,7 @@ fn tally(extra: impl FnOnce(&mut W) -> String) -> Vec { w.build(&format!("{pre} (if {inc} (then {two} (return (i32.const 0)))) (if {dec} (then {zero} (return (i32.const 0)))) {one}")) } -fn widget_manifest(id: &str, access: &str) -> Value { +pub(crate) fn widget_manifest(id: &str, access: &str) -> Value { json!({"id": id, "name": "Tally", "version": "1.0.0", "main": "w.wasm", "api": 3, "widget": true, "permissions": {"access": access}}) } @@ -128,7 +128,7 @@ fn click(s: &mut Session, w: NodeId, target: &str) -> Value { ex(s, "widget.click", json!({"id": w, "target": target})) } -fn state(s: &mut Session, w: NodeId) -> Value { +pub(crate) fn state(s: &mut Session, w: NodeId) -> Value { let list = ex(s, "widget.list", json!({})); list["widgets"].as_array().unwrap().iter().find(|x| x["id"] == json!(w)).map(|x| x["state"].clone()).unwrap_or(Value::Null) } @@ -139,7 +139,7 @@ fn label(s: &Session, w: NodeId) -> String { node(s, first).kind.text().unwrap().characters.clone() } -fn err(s: &mut Session, cmd: &str, p: Value) -> String { +pub(crate) fn err(s: &mut Session, cmd: &str, p: Value) -> String { match s.execute(cmd, &p) { Ok(v) => panic!("{cmd} accepted {p}: {v}"), Err(e) => { diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 80a26295..31e5234b 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -11,7 +11,7 @@ pub mod widget; pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand}; pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run}; -pub use widget::{WidgetKind, WidgetNode, WidgetRender}; +pub use widget::{MenuItem, MenuKind, WidgetKind, WidgetNode, WidgetRender}; #[cfg(test)] mod bundle_tests; diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index fc4839e3..92cfac7b 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -43,7 +43,7 @@ pub struct Manifest { /// The WebAssembly module, relative to the manifest (`plugin.wasm`). pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings - /// need 2, widgets 3, `saveFile` 4). + /// need 2, widgets 3, `saveFile`, widget fields and property menus 4). #[serde(default = "api_version", deserialize_with = "api_from_number_or_string")] pub api: u32, #[serde(default)] diff --git a/crates/plugin/src/widget.rs b/crates/plugin/src/widget.rs index 32ee7d34..be202c97 100644 --- a/crates/plugin/src/widget.rs +++ b/crates/plugin/src/widget.rs @@ -1,5 +1,5 @@ -//! Widgets (ABI v3): a plugin draws a layer on the canvas by describing it, and FigCraft builds the -//! layers. The description comes from the plugin, so it is untrusted: [`WidgetRender::parse`] +//! Widgets (ABI v3; editable text fields and a property menu since v4): a plugin draws a layer on +//! the canvas by describing it, and FigCraft builds the layers. The description comes from the plugin, so it is untrusted: [`WidgetRender::parse`] //! checks its size, node count, depth, numbers, strings, colours and image sizes before the engine //! sees it. The engine decides where the layers go (only inside the widget's own layer). @@ -24,6 +24,12 @@ const MAX_SIZE: f64 = 4000.0; /// Largest image (decoded bytes), and all images of one widget together. pub const MAX_IMAGE_BYTES: usize = 1024 * 1024; const MAX_IMAGES_BYTES: usize = 2 * 1024 * 1024; +/// Longest value of an editable text field (the same as any text layer's). +pub const MAX_FIELD_CHARS: usize = MAX_TEXT; +/// Most items in a widget's property menu, most colours in one colour item, longest item label. +const MAX_MENU: usize = 8; +const MAX_SWATCHES: usize = 8; +const MAX_MENU_LABEL: usize = 32; /// What a widget is made of: FigCraft's own layer kinds. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -65,6 +71,10 @@ pub struct WidgetNode { /// Clicking this layer calls the plugin back with this name (empty: not clickable). #[serde(default, skip_serializing_if = "String::is_empty")] pub target: String, + /// API 4, text layers: the text is an editable field with this name; an edit calls the plugin + /// back with the field and its new value, which is also kept in the state under this name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub field: String, /// Position in the parent frame (ignored inside auto layout and for the widget itself). #[serde(default, skip_serializing_if = "Option::is_none")] pub x: Option, @@ -119,6 +129,38 @@ pub struct WidgetRender { #[serde(default, skip_serializing_if = "Option::is_none")] pub state: Option>, pub root: WidgetNode, + /// API 4: the widget's property menu, shown while it's selected (empty: none). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub menu: Vec, +} + +/// What a property menu item is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MenuKind { + /// A button. + #[default] + Action, + /// On or off (`checked`). + Toggle, + /// One of a few colours (`options`; `value` is the current one). + Color, +} + +/// One item of a widget's property menu; choosing it calls the plugin back with `item`. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct MenuItem { + pub item: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub label: String, + #[serde(rename = "type", default)] + pub kind: MenuKind, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub checked: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub options: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, } /// What one description may still use while it's checked. @@ -141,8 +183,74 @@ impl WidgetRender { return Err("widget: the root must be a frame".into()); } w.root.check(0, &mut Budget { nodes: 0, image_bytes: 0 }).map_err(|e| format!("widget: {e}"))?; + check_menu(&mut w.menu).map_err(|e| format!("widget: {e}"))?; Ok(w) } + + /// Whether it uses what plugin API 4 added (fields or a menu). + pub fn uses_api_4(&self) -> bool { + fn field(n: &WidgetNode) -> bool { + !n.field.is_empty() || n.children.iter().any(field) + } + !self.menu.is_empty() || field(&self.root) + } +} + +/// Check and normalise a property menu: at most [`MAX_MENU`] items with distinct names and short +/// labels (an item's name when it has none); a colour item has 1–[`MAX_SWATCHES`] colours, its +/// value one of them. +pub fn check_menu(menu: &mut [MenuItem]) -> Result<(), String> { + if menu.len() > MAX_MENU { + return Err(format!("a menu has more than {MAX_MENU} items")); + } + for i in 0..menu.len() { + let Some(m) = menu.get_mut(i) else { continue }; + check_id("menu item", &m.item, MAX_NAME)?; + m.label = clean("a menu label", &m.label, MAX_MENU_LABEL)?; + if m.label.is_empty() { + m.label.clone_from(&m.item); + } + let colours = m.kind == MenuKind::Color; + if m.checked && m.kind != MenuKind::Toggle { + return Err("`checked` is for toggles".into()); + } + if colours == m.options.is_empty() || m.options.len() > MAX_SWATCHES { + return Err(format!("a colour item needs 1–{MAX_SWATCHES} `options` (and only it has them)")); + } + for o in &mut m.options { + let mut c = Some(std::mem::take(o)); + colour("options", &mut c)?; + *o = c.unwrap_or_default(); + } + colour("value", &mut m.value)?; + if m.value.as_ref().is_some_and(|v| !m.options.contains(v)) { + return Err("a colour item's `value` must be one of its `options`".into()); + } + let item = m.item.clone(); + if menu.get(..i).is_some_and(|before| before.iter().any(|b| b.item == item)) { + return Err(format!("two menu items are called `{item}`")); + } + } + Ok(()) +} + +/// An editable field's new value, cleaned: line endings become `\n`, tabs spaces, and other control +/// characters are dropped; at most [`MAX_FIELD_CHARS`]. +pub fn clean_field(value: &str) -> Result { + let v: String = value + .replace("\r\n", "\n") + .chars() + .filter_map(|c| match c { + '\r' | '\n' => Some('\n'), + '\t' => Some(' '), + c if c.is_control() => None, + c => Some(c), + }) + .collect(); + if v.chars().count() > MAX_FIELD_CHARS { + return Err(format!("a field's value is longer than {MAX_FIELD_CHARS} characters")); + } + Ok(v) } /// A widget's state: a JSON object of at most [`MAX_STATE_BYTES`] with short keys. @@ -186,6 +294,9 @@ impl WidgetNode { if !self.target.is_empty() { check_id("target", &self.target, MAX_NAME)?; } + if !self.field.is_empty() { + check_id("field", &self.field, MAX_NAME)?; + } for (what, v) in [("x", self.x), ("y", self.y)] { within(what, v, -MAX_SIZE, MAX_SIZE)?; } @@ -210,6 +321,7 @@ impl WidgetNode { )?; only("text", !self.text.is_empty() || self.size.is_some() || self.color.is_some() || self.bold, kind == WidgetKind::Text)?; only("data", !self.data.is_empty(), kind == WidgetKind::Image)?; + only("field", !self.field.is_empty(), kind == WidgetKind::Text)?; if self.text.chars().count() > MAX_TEXT { return Err(format!("a text is longer than {MAX_TEXT} characters")); } diff --git a/crates/plugin/src/widget_tests.rs b/crates/plugin/src/widget_tests.rs index 35418866..1074503d 100644 --- a/crates/plugin/src/widget_tests.rs +++ b/crates/plugin/src/widget_tests.rs @@ -3,8 +3,8 @@ use serde_json::{Value, json}; -use crate::widget::{MAX_IMAGE_BYTES, MAX_NODES, MAX_STATE_BYTES}; -use crate::{Host, Instance, Limits, Manifest, WidgetKind, WidgetRender}; +use crate::widget::{MAX_FIELD_CHARS, MAX_IMAGE_BYTES, MAX_NODES, MAX_STATE_BYTES, clean_field}; +use crate::{Host, Instance, Limits, Manifest, MenuKind, WidgetKind, WidgetRender}; struct Quiet; @@ -114,3 +114,47 @@ fn widgets_need_api_3() { let v2 = Manifest::from_value(&json!({"id": "p", "name": "P", "main": "p.wasm", "api": 2})).unwrap(); assert!(!v2.widget && v2.commands.len() == 1, "v2 manifests are unchanged"); } + +#[test] +fn fields_and_menus_are_checked_and_normalised() { + let root = json!({"type": "frame", "children": [{"type": "text", "field": "label", "text": "Hi"}]}); + let w = + parse(json!({"root": root, "menu": [{"item": "reset"}, {"item": "c", "type": "color", "options": ["#ff0000"], "value": "ff0000"}]})).unwrap(); + assert!(w.uses_api_4()); + assert_eq!((w.menu[0].label.as_str(), w.menu[0].kind), ("reset", MenuKind::Action), "a label defaults to the item"); + assert_eq!((w.menu[1].options.clone(), w.menu[1].value.clone()), (vec!["#FF0000".to_string()], Some("#FF0000".to_string()))); + assert!(!parse(json!({"root": {"type": "frame"}})).unwrap().uses_api_4(), "API 3 descriptions are unchanged"); + let menu = |m: Value| json!({"root": {"type": "frame"}, "menu": m}); + let field = |kind: &str, name: &str| json!({"root": {"type": "frame", "children": [{"type": kind, "field": name}]}}); + let junk = [ + menu(json!(vec![json!({"item": "a"}); 9])), + menu(json!([{"item": "a"}, {"item": "a"}])), + menu(json!([{"item": "has space"}])), + menu(json!([{"item": ""}])), + menu(json!([{"item": "a", "label": "x".repeat(33)}])), + menu(json!([{"item": "a", "label": "line\nbreak"}])), + menu(json!([{"item": "a", "type": "slider"}])), + menu(json!([{"item": "a", "checked": true}])), + menu(json!([{"item": "a", "type": "color"}])), + menu(json!([{"item": "a", "type": "color", "options": vec!["#000000"; 9]}])), + menu(json!([{"item": "a", "type": "color", "options": ["red"]}])), + menu(json!([{"item": "a", "type": "color", "options": ["#000000"], "value": "#FFFFFF"}])), + menu(json!([{"item": "a", "options": ["#000000"]}])), + menu(json!({"item": "a"})), + field("rectangle", "label"), + field("frame", "label"), + field("text", "has space"), + field("text", &"x".repeat(65)), + ]; + for j in junk { + assert!(parse(j.clone()).is_err(), "accepted {}", j.to_string().chars().take(100).collect::()); + } +} + +#[test] +fn field_values_are_cleaned_and_bounded() { + assert_eq!(clean_field("a\r\nb\rc\td\u{7}\u{0}é").unwrap(), "a\nb\nc dé"); + assert_eq!(clean_field(&"é".repeat(MAX_FIELD_CHARS)).unwrap().chars().count(), MAX_FIELD_CHARS); + assert!(clean_field(&"x".repeat(MAX_FIELD_CHARS + 1)).is_err()); + assert_eq!(clean_field("").unwrap(), ""); +} diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 09ff2e44..cccea99b 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1839,3 +1839,5 @@ status = "draft — needs native review" "tool.shape" = "আকৃতি" "tool.stamp" = "স্ট্যাম্প" "tool.sticky" = "স্টিকি নোট" +"widget.menu" = "উইজেটের মেনু" +"widget.setText" = "উইজেটের ঘর সম্পাদনা" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 50775f76..f3d04ec7 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -2367,6 +2367,8 @@ status = "draft — needs native review" "widget.click" = "Kliknout na widget" "widget.insert" = "Vložit widget" "widget.list" = "Vypsat widgety" +"widget.menu" = "Nabídka widgetu" +"widget.setText" = "Upravit pole widgetu" "window.focus" = "Přenést okno do popředí" "window.new" = "Nové okno" "template.list" = "Vypsat šablony" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index dff589cd..5a555d51 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -2317,6 +2317,8 @@ status = "draft — needs native review" "widget.click" = "Widget anklicken" "widget.insert" = "Widget einfügen" "widget.list" = "Widgets auflisten" +"widget.menu" = "Widget-Menü" +"widget.setText" = "Widget-Feld bearbeiten" "window.focus" = "Fenster in den Vordergrund holen" "window.new" = "Neues Fenster" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index ef34872e..b5e19581 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2459,6 +2459,8 @@ status = "draft — needs native review" "widget.click" = "Pulsar widget" "widget.insert" = "Insertar widget" "widget.list" = "Listar widgets" +"widget.menu" = "Menú del widget" +"widget.setText" = "Editar campo del widget" "window.focus" = "Traer la ventana al frente" "window.new" = "Nueva ventana" "template.list" = "Listar plantillas" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 0bfcd574..95b9f965 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -2416,6 +2416,8 @@ status = "draft — needs native review" "widget.click" = "Napsauta pienoisohjelmaa" "widget.insert" = "Lisää pienoisohjelma" "widget.list" = "Luettele pienoisohjelmat" +"widget.menu" = "Pienoisohjelman valikko" +"widget.setText" = "Muokkaa pienoisohjelman kenttää" "window.focus" = "Tuo ikkuna etummaiseksi" "window.new" = "Uusi ikkuna" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b7a00ea0..9a6b3c67 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -2416,6 +2416,8 @@ status = "draft — needs native review" "widget.click" = "Cliquer sur le widget" "widget.insert" = "Insérer un widget" "widget.list" = "Lister les widgets" +"widget.menu" = "Menu du widget" +"widget.setText" = "Modifier le champ du widget" "window.focus" = "Mettre la fenêtre au premier plan" "window.new" = "Nouvelle fenêtre" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 6878bee1..aee866b1 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -2371,6 +2371,8 @@ status = "draft — needs native review" "widget.click" = "Klik widget" "widget.insert" = "Sisipkan widget" "widget.list" = "Daftar widget" +"widget.menu" = "Menu widget" +"widget.setText" = "Edit kolom widget" "window.focus" = "Bawa jendela ke depan" "window.new" = "Jendela baru" "template.list" = "Daftar templat" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index e792ec72..30650bbc 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -2394,6 +2394,8 @@ status = "draft — needs native review" "widget.click" = "ウィジェットをクリック" "widget.insert" = "ウィジェットを挿入" "widget.list" = "ウィジェットを一覧表示" +"widget.menu" = "ウィジェットのメニュー" +"widget.setText" = "ウィジェットのフィールドを編集" "window.focus" = "ウィンドウを最前面に表示" "window.new" = "新しいウィンドウ" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 8b935314..8b86338a 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -2407,6 +2407,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "widget.click" = "ვიჯეტზე დაწკაპუნება" "widget.insert" = "ვიჯეტის ჩასმა" "widget.list" = "ვიჯეტების სია" +"widget.menu" = "ვიჯეტის მენიუ" +"widget.setText" = "ვიჯეტის ველის რედაქტირება" "window.focus" = "ფანჯრის წინა პლანზე გადმოტანა" "window.new" = "ახალი ფანჯარა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 6f497729..3f8dad86 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -2308,6 +2308,8 @@ status = "draft — needs native review" "widget.click" = "위젯 클릭" "widget.insert" = "위젯 삽입" "widget.list" = "위젯 목록" +"widget.menu" = "위젯 메뉴" +"widget.setText" = "위젯 필드 편집" "window.focus" = "창을 맨 앞으로 가져오기" "window.new" = "새 창" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 7ed56cab..6cac7aa9 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -2411,6 +2411,8 @@ status = "draft — needs native review" "widget.click" = "Op widget klikken" "widget.insert" = "Widget invoegen" "widget.list" = "Widgets weergeven" +"widget.menu" = "Widgetmenu" +"widget.setText" = "Widgetveld bewerken" "window.focus" = "Venster naar voren brengen" "window.new" = "Nieuw venster" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index d013bbe2..a435726c 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -2429,6 +2429,8 @@ name = "Polski" "widget.click" = "Kliknij widżet" "widget.insert" = "Wstaw widżet" "widget.list" = "Lista widżetów" +"widget.menu" = "Menu widżetu" +"widget.setText" = "Edytuj pole widżetu" "window.focus" = "Przesuń okno na wierzch" "window.new" = "Nowe okno" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 550c6f2e..ff73e5bc 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -2422,6 +2422,8 @@ status = "draft — needs native review" "widget.click" = "Clicar no widget" "widget.insert" = "Inserir widget" "widget.list" = "Listar widgets" +"widget.menu" = "Menu do widget" +"widget.setText" = "Editar campo do widget" "window.focus" = "Trazer a janela para a frente" "window.new" = "Nova janela" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index e141b9c8..7d1d1000 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -2430,6 +2430,8 @@ name = "Русский" "widget.click" = "Нажать виджет" "widget.insert" = "Вставить виджет" "widget.list" = "Список виджетов" +"widget.menu" = "Меню виджета" +"widget.setText" = "Изменить поле виджета" "window.focus" = "Вывести окно на передний план" "window.new" = "Новое окно" "template.list" = "Список шаблонов" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 1af7c5e4..0dfbb2fa 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -2432,6 +2432,8 @@ name = "Svenska" "widget.click" = "Klicka på widget" "widget.insert" = "Infoga widget" "widget.list" = "Lista widgetar" +"widget.menu" = "Widgetmeny" +"widget.setText" = "Redigera widgetfält" "window.focus" = "Placera fönstret längst fram" "window.new" = "Nytt fönster" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 958dcb59..58166c93 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -2371,6 +2371,8 @@ name = "Türkçe" "widget.click" = "Widget'a tıkla" "widget.insert" = "Widget ekle" "widget.list" = "Widget'ları listele" +"widget.menu" = "Widget menüsü" +"widget.setText" = "Widget alanını düzenle" "window.focus" = "Pencereyi öne getir" "window.new" = "Yeni pencere" "template.list" = "Şablonları listele" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 4fe87062..a3c5c92e 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -2322,6 +2322,8 @@ status = "draft — needs native review" "widget.click" = "点击小组件" "widget.insert" = "插入小组件" "widget.list" = "列出小组件" +"widget.menu" = "小组件菜单" +"widget.setText" = "编辑小组件字段" "window.focus" = "将窗口置于前台" "window.new" = "新建窗口" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ca82afa8..d81571c1 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -275,6 +275,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::board_bar::stamp_author(app, &painter, ctx.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p))); crate::board_bar::show(app, &ctx); crate::board_facilitate::show(app, &ctx, rect); + crate::widget_bar::show(app, &ctx); crate::selection_spacing::draw(app, &painter); crate::comments::draw(app, &painter); crate::live_text::draw(app, &painter); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bbc39628..c11bf985 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -70,6 +70,7 @@ pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; pub mod versions; +pub mod widget_bar; pub mod widgets; pub mod windows; diff --git a/crates/ui-egui/src/widget_bar.rs b/crates/ui-egui/src/widget_bar.rs new file mode 100644 index 00000000..c32002d6 --- /dev/null +++ b/crates/ui-egui/src/widget_bar.rs @@ -0,0 +1,113 @@ +//! The small bar floating over a selected plugin widget that has a property menu: its actions and +//! toggles as buttons, its colour choices as swatches. Each calls `widget.menu`; the labels are the +//! plugin's own. + +use egui::{CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_engine::widgets::{MenuItem, MenuKind, menu_of}; +use serde_json::json; + +use crate::FigApp; +use crate::a11y; +use crate::board_bar::{Mark, divider, paint_choice, place}; +use crate::theme::{Tokens, semibold}; + +const HEIGHT: f32 = 40.0; +const CELL: f32 = 28.0; +const SWATCH: f32 = 20.0; +const GAP: f32 = 4.0; +/// A text button's padding on each side. +const PAD: f32 = 8.0; + +/// What one item takes across the bar, its trailing gap included (text buttons by their label). +fn width(ctx: &egui::Context, m: &MenuItem) -> f32 { + match m.kind { + MenuKind::Color => m.options.len() as f32 * (SWATCH + GAP), + _ => ctx.fonts_mut(|f| f.layout_no_wrap(m.label.clone(), semibold(11.0), egui::Color32::WHITE).size().x) + PAD * 2.0 + GAP, + } +} + +/// The bar above the selected widget, while it is the only layer selected, nothing is being dragged +/// or edited and the file can be edited. +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + if app.session.tool.busy() || app.session.active_read_only().is_some() { + return; + } + let Ok(ds) = app.session.ds() else { return }; + let ([w], None) = (ds.selection.as_slice(), &ds.text_edit) else { return }; + let w = *w; + let menu = menu_of(&ds.doc, w); + let Some(b) = ds.doc.world_bounds(w).filter(|_| !menu.is_empty()) else { return }; + let sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + // Scrolled out of view: no bar (it would float over the rest of the window). + if !sel.intersects(app.canvas.rect) { + return; + } + let widths: Vec = menu.iter().map(|m| width(ctx, m)).collect(); + // The items, a divider (8) between each two, and the padding. + let size = vec2(8.0 + widths.iter().sum::() + 8.0 * (menu.len() - 1) as f32 + 4.0, HEIGHT); + let at = place(sel, size, app.canvas.rect); + let t = Tokens::get(ctx); + egui::Area::new(egui::Id::unique("widget_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(size, Sense::click()); + a11y::container_bounds(ui, rect); + let p = ui.painter(); + p.rect_filled(rect.expand(2.0).translate(vec2(0.0, 1.0)), CornerRadius::same(12), egui::Color32::from_black_alpha(18)); + p.rect_filled(rect, CornerRadius::same(10), t.panel); + p.rect_stroke(rect, CornerRadius::same(10), Stroke::new(1.0, t.border), StrokeKind::Inside); + let cy = rect.center().y; + let mut x = rect.min.x + 8.0; + for (i, (m, wd)) in menu.iter().zip(&widths).enumerate() { + if i > 0 { + x = divider(ui, &t, x, cy); + } + if let Some(value) = item(ui, &t, m, x, cy, *wd) { + app.run("widget.menu", json!({"id": w, "item": m.item, "value": value})); + } + x += wd; + } + }); +} + +/// Draw item `m` from `x`, `wd` wide; the value to send when it's used. +fn item(ui: &mut Ui, t: &Tokens, m: &MenuItem, x: f32, cy: f32, wd: f32) -> Option { + if m.kind == MenuKind::Color { + let mut picked = None; + for (i, hex) in m.options.iter().enumerate() { + let r = Rect::from_center_size(pos2(x + i as f32 * (SWATCH + GAP) + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); + let tip = format!("{} {hex}", m.label); + let resp = ui.interact(r, ui.scope_id().with(("wb_color", &m.item, i)), Sense::click()).on_hover_text(&tip); + let on = m.value.as_ref() == Some(hex); + a11y::toggle(&resp, &tip, on); + if on { + ui.painter().rect_stroke(r.expand(2.5), CornerRadius::same(6), Stroke::new(1.5, t.text), StrokeKind::Inside); + } else if resp.hovered() { + ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); + } + if let Some(c) = figcraft_color::Color::from_hex(hex) { + paint_choice(ui.painter(), r, &Mark::Color(c), t.icon); + } + if resp.clicked() && !on { + picked = Some(json!(hex)); + } + } + return picked; + } + let r = Rect::from_min_size(pos2(x, cy - CELL / 2.0), vec2(wd - GAP, CELL)); + let resp = ui.interact(r, ui.scope_id().with(("wb_item", &m.item)), Sense::click()); + let on = m.kind == MenuKind::Toggle && m.checked; + if m.kind == MenuKind::Toggle { + a11y::toggle(&resp, &m.label, on); + } else { + a11y::button(&resp, &m.label); + } + let bg = if on { + t.accent + } else if resp.hovered() { + t.hover + } else { + egui::Color32::TRANSPARENT + }; + ui.painter().rect_filled(r, CornerRadius::same(5), bg); + ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, &m.label, semibold(11.0), if on { t.accent_on } else { t.text }); + resp.clicked().then(|| if m.kind == MenuKind::Toggle { json!(!m.checked) } else { serde_json::Value::Null }) +} diff --git a/docs/gaps.md b/docs/gaps.md index ba4e0294..9f95b4f5 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-25 · **Change:** minor (gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); 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-25 · **Change:** minor (gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); 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 @@ -19,7 +19,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | | 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) | -| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) | +| 6 | Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | | 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | @@ -207,7 +207,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe [`localization-parity.md`](localization-parity.md); how to add one in [`translating.md`](translating.md). -### 6. Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run +### 6. Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run - **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in the browser) acting through the command registry, one undo step per run, manifest permissions, Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples @@ -224,15 +224,20 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe key and checked in full (`figcraft-cli plugin publish`), browsed, installed after a permissions confirmation and updated from Plugins ▸ Manage plugins ▸ Browse, downloads checked against their SHA-256, `plugin.directory.*` for scripts and MCP, moderation with `figcraft-server plugins`. + Widget fields and property menus (plugin API 4, 2026-10-24): a widget's text layer can be an + editable field, edited on the canvas with a double-click like any text or with `widget.setText` + (the value, cleaned and capped, goes into the widget's state and the plugin redraws; one undo + step), and a widget can carry a property menu of actions, toggles and colour choices, shown in a + small bar while it's selected and driven with `widget.menu`; the Counter example uses both. - **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings - and install counts, background update checks, the directory in the browser build; widgets take only clicks (no - text input, property menus, hover or timers) and draw only frames, text, shapes and images; + and install counts, background update checks, the directory in the browser build; widgets have no hover or + timers, can't open a panel and draw only frames, text, shapes and images; no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and don't run. - **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better than Figma does. -- **Estimate:** 8–26 h for the missing pieces of FigCraft's own API. Running Figma's plugins +- **Estimate:** 6–22 h for the missing pieces of FigCraft's own API. Running Figma's plugins needs a JS engine and an API shim: **owner decision needed**. ### 7. CPU canvas where Figma uses the GPU @@ -501,6 +506,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2026-10-24 | minor | Gap 16: Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | | 2026-10-24 | minor | Gap 24: a shared board timer and voting sessions landed (M12); 30–60 h → 26–54 h, ranking unchanged | | 2026-10-24 | minor | Gap 24: board templates (retrospective, brainstorm, weekly planner, flow chart, kanban board; `board.template`, a toolbar picker) and tables (`board.table`, `board.tableEdit`); 30–60 h → 26–54 h, rank unchanged | +| 2026-10-24 | minor | Gap 6: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`, a bar over the selected widget), plugin API 4; 8–26 h → 6–22 h; ranking unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 7f8955a5..c630a85f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1429,17 +1429,24 @@ Plugin settings are kept per plugin with the preferences (in a headless session, {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.event","params":{"id":"org.figcraft.examples.swatch-grid","control":"generate"}}}} ``` -Widgets (API 3, layers on the canvas a plugin draws) are driven the same way: `widget.insert +Widgets (API 3 and 4, layers on the canvas a plugin draws) are driven the same way: `widget.insert {plugin, x?, y?}` puts one on the page (centred in the view without a position) and returns `{id, plugin, state, targets, output, logs, calls, fuel}`; `widget.click {id, target}` clicks one of its `targets` as a click on the canvas would (the plugin redraws it; one undo step; returns the same); -`widget.list` lists every widget in the file with its plugin, page, state and targets. A widget's -state is layer data, so it is saved, undone and synced with the file. +`widget.list` lists every widget in the file with its plugin, page, state and targets. With plugin +API 4 a widget can have editable text fields and a property menu (both listed with it, as `fields` +and `menu`): `widget.setText {id, field, value}` sets a field as typing in it would (the value, +at most 1,000 characters, goes into the state under the field's name and the plugin redraws; one +undo step), and `widget.menu {id, item, value?}` uses a menu item (a toggle's `value` is `true` or +`false`, by default the opposite of now; a colour's is one of its options). A widget's state is +layer data, so it is saved, undone and synced with the file. ```json {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.insert","params":{"plugin":"org.figcraft.examples.counter"}}}} {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.click","params":{"id":12,"target":"increment"}}}} {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.list","params":{}}}} +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.setText","params":{"id":12,"field":"label","value":"Votes"}}}} +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"widget.menu","params":{"id":12,"item":"big"}}}} ``` Plugins from a `figcraft-server`'s plugin directory ([`plugins.md`](plugins.md#plugin-directories)): diff --git a/docs/plugins.md b/docs/plugins.md index 73c93cf6..2d4001bc 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -18,7 +18,8 @@ permissions its manifest asks for, and nothing more. plugin can also keep settings from one run to the next. - **Widgets (API 3).** A plugin can put a layer of its own on the canvas: it describes the layer (frames with auto layout, text, shapes, images), FigCraft builds it, and a click on one of its - parts calls the plugin back to redraw it from its state, which is kept in the document. + parts calls the plugin back to redraw it from its state, which is kept in the document. Since + API 4 a widget can also have editable text fields and a small property menu. - **Permissions.** The manifest says whether the plugin only reads the document or may edit it, and can narrow the commands it may call. FigCraft enforces both. - **Distribution.** A team's own `figcraft-server` can keep a plugin directory: plugins are @@ -91,6 +92,17 @@ and its state (a small JSON object) in its own layer data, so a saved file, a co a live session carries the widget as it is, and a copy is a widget of its own. A widget whose plugin isn't installed stays as it was drawn; clicking it says the plugin is missing. +**Fields and menus (API 4).** A text layer of a widget can be an editable field: double-click it +with the Move tool and it is edited like any text (all of it selected first). When editing ends +(Escape, or a click elsewhere), the typing is put back and the new value goes to the widget +instead: it is cleaned (line breaks kept, tabs become spaces, other control characters dropped), +refused when longer than 1,000 characters, written into the widget's state under the field's +name and into the field's text, and the plugin is called back to redraw, all as one undo step +(none when the text didn't change). The value lives in the state, so it syncs and saves like the +rest of it. A widget can also carry a property menu: while it is the only layer selected, a +small bar over it shows its actions and toggles as buttons and its colour choices as swatches; +using one calls the plugin back, one undo step. The labels are the plugin's own text. + In a presented prototype (the presenter or the preview pane) clicks on a widget's clickable parts call its plugin back the same way, with the same limits, but on the prototype's own copy of the file: the widget changes in the prototype only, the file and its undo history stay as they are, @@ -115,6 +127,8 @@ From scripts, the CLI and MCP the same commands apply, panels included: {"command": "widget.insert", "params": {"plugin": "org.figcraft.examples.counter", "x": 0, "y": 0}} {"command": "widget.list", "params": {}} {"command": "widget.click", "params": {"id": 12, "target": "increment"}} +{"command": "widget.setText", "params": {"id": 12, "field": "label", "value": "Votes"}} +{"command": "widget.menu", "params": {"id": 12, "item": "color", "value": "#14AE5C"}} ``` `figcraft-cli run script.jsonl --in design.figcraft --out tidy.figcraft` runs that headless; over @@ -132,9 +146,11 @@ plugins: import them in the script. | `plugin.close` | `{id}` | Closes plugin `id`'s panel and ends its session. `{closed}` says whether one was open. | | `plugins.runLast` | `{}` | Runs the last plugin command again. | | `plugins.manage` | `{}` | Opens the Plugins window; returns the list like `plugin.list`. | -| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. | +| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, fields, menu, output, logs, calls, fuel}`: `targets` are the names of its clickable parts, `fields` of its editable fields, `menu` its property menu items. | | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | -| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. | +| `widget.setText` | `{id, field, value, fuel?}` | Sets editable field `field` of widget `id` (or of the widget a layer `id` is in) to the text `value`, as editing it on the canvas would: the value (at most 1,000 characters, cleaned) goes into the state under the field's name and into the field's text, and the plugin is called back. One undo step; returns like `widget.insert`. | +| `widget.menu` | `{id, item, value?, fuel?}` | Uses item `item` of widget `id`'s property menu, as its bar would: an action takes no `value`; a toggle takes `true` or `false` (default: the opposite of now); a colour takes one of its options (hex, any case). The plugin is called back. One undo step; returns like `widget.insert`. | +| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets, fields, menu}]}`: every widget in the file. | | `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | | `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. | @@ -224,7 +240,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `version`, `description` | Optional text. A plugin published to a [directory](#plugin-directories) needs a `version` of one to four numbers (`1.2.0`), each version newer than the last. | | `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. | | `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. | -| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | +| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files, widget fields and property menus). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. | | `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"). A plugin directory's entries don't carry it (their wire format stays as it is), so installing any API 4 plugin from one says it may offer files to save. | | `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). | | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | @@ -263,7 +279,9 @@ WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100 16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and -its images 1 MB each, 2 MB together and 4096 pixels on a side. +its images 1 MB each, 2 MB together and 4096 pixels on a side. A property menu has at most 8 +items (labels up to 32 characters), a colour item 1–8 colours; a field's value is at most 1,000 +characters. Copying a byte across the guest-memory boundary costs one additional fuel, reserved before the allocation or copy. This also applies when the configured per-call overhead is zero. A reply @@ -272,9 +290,11 @@ every ABI import, including calls which only query the reply length. ## ABI (version 4) -Version 2 adds panels and settings to version 1, version 3 adds widgets and version 4 adds -`save_file`; a version 1, 2 or 3 -module runs unchanged and gets the input it always got. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values +Version 2 adds panels and settings to version 1, version 3 adds widgets, and version 4 adds +`save_file` and widget fields and property menus (new keys in the widget description and new +widget events); a module written for an older version runs unchanged and gets the input it always +got. A widget description with a field or a menu from a plugin whose manifest says `3` is refused, +and a FigCraft that runs an older API refuses a manifest that asks for `4` when it's imported. Strings are UTF-8 `(ptr, len)` pairs in the plugin's linear memory; all values are `i32`. The module **exports** `memory` and `figcraft_run(input_len) -> status`. When `figcraft_run` starts, @@ -300,6 +320,15 @@ for a new widget) and what was clicked (`event` is null when it's inserted): {"api": 3, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"target": "increment"}}} ``` +With API 4 the event can also be an edited field (its value is already in `state` under the +field's name) or a property menu item (`value`: null for an action, the new `true`/`false` for a +toggle, the colour picked for a colour item): + +```json +{"api": 4, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2, "label": "Votes"}, "event": {"field": "label", "value": "Votes"}}} +{"api": 4, "command": "widget", "params": {}, "selection": [], "widget": {"id": 12, "state": {"count": 2}, "event": {"menu": "color", "value": "#14AE5C"}}} +``` + It returns `0` for success; anything else fails the run (and rolls it back), with the text passed to `set_output` as the error message. @@ -370,11 +399,24 @@ ignored in auto layout), `width` and `height`, `fill` and `stroke` (hex colours) `vertical`), `gap`, `padding`, `justify` and `align` (`min`, `center`, `max`: along and across the layout) and `children`; with auto layout it hugs its contents on an axis without a size. A text has `text`, `size`, -`color` and `bold` (with a `width` it wraps). An image has `data`, the image's bytes (PNG, JPEG…) +`color` and `bold` (with a `width` it wraps), and with API 4 may have a `field` (a name like a +target's): the text is then an editable field showing its current value. An image has `data`, the image's bytes (PNG, JPEG…) as base64, usually carried in the module itself; without a size it takes the image's. FigCraft checks the whole description (kinds, sizes, numbers, colours, lengths, counts, depth, image sizes) and refuses it if anything is off. +With API 4, `menu` beside `root` is the widget's property menu (left out: none), for example: + +```json +"menu": [{"item": "reset", "label": "Reset"}, + {"item": "big", "label": "Steps of 10", "type": "toggle", "checked": true}, + {"item": "color", "label": "Colour", "type": "color", "options": ["#1E1E1E", "#0D99FF"], "value": "#0D99FF"}] +``` + +Each item has an `item` name (like a target's, each once), a `label` (default: the name) and a +`type`: `action` (the default), `toggle` (with `checked`) or `color` (with `options`, 1–8 hex +colours, and optionally `value`, one of them: the current one). Every redraw replaces the menu. + ## Writing a plugin in Rust `plugins/` in the repository is a workspace of its own: `plugins/sdk` (the crate @@ -390,8 +432,9 @@ image sizes) and refuses it if anything is off. button; the palette picked last is remembered as a setting for next time. **From hex colours…** takes a palette of hex colours in the parameters form. -- **Counter** (`counter`, API 3): a widget, a count between − and + buttons; each click changes the - count kept in the widget's state. It only reads the document. +- **Counter** (`counter`, API 4): a widget, a count between − and + buttons under a label; each + click changes the count kept in the widget's state, a double-click on the label edits it, and its + menu resets the count, counts in tens or colours it. It only reads the document. - **Export names** (`export-names`, API 4): saves the names of the selected layers (optionally the layers inside them too) as `layer-names.txt`. It reads the document and offers the file with `save_file`; FigCraft asks where it goes. @@ -455,8 +498,10 @@ rolls it back. With `"api": 2` in the manifest: `show_ui(&json!({...}))` and `cl panel, `setting(key)`, `set_setting(key, &value)` and `delete_setting(key)` for settings. With `"api": 3` and `"widget": true`: `input.widget` (`id`, `state`, `target`) when the widget is inserted or clicked, and `render_widget(&json!({"state": ..., "root": ...}))` to draw it. With -`"api": 4` and `"permissions": {"saveFile": true}`: `save_file(name, mime, &bytes)` offers a file; -FigCraft asks the user where it goes and the plugin never learns a path. Outside +`"api": 4`, `input.widget` also has `field` or `menu` and the `value` that came with it, and the +description may have fields and a `menu`; with `"permissions": {"saveFile": true}` too, +`save_file(name, mime, &bytes)` offers a file; FigCraft asks the user where it goes and the plugin +never learns a path. Outside WebAssembly every host call fails, so the plugin's own logic can be unit-tested with `cargo test`. Any language that compiles to WebAssembly works the same way: export `memory` and `figcraft_run`, @@ -469,8 +514,8 @@ import what you need from `figcraft`. install counts, and updates are found when you browse, not in the background. The browser build can't use a directory yet. - Plugins written for other apps (JavaScript plugins) don't run. -- Widgets: clicks are their only events (no text input, hover, property menu or timers), they - can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no +- Widgets: clicks, field edits and menu items are their only events (no hover or timers), a field + is plain text (no placeholder, length or single-line setting), they can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no vectors, components or variables). - Panels: no images, sliders or free layout inside a panel. - Data in the document: plugins keep data on layers (pages included) with `node.set {props: diff --git a/docs/roadmap.md b/docs/roadmap.md index 4340d4db..c22f88f8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -59,7 +59,7 @@ blockers. | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | | M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts | native reviews, Hindi, Vietnamese, Arabic (with mirrored panels), engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 | -| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update) | a public catalogue, publisher accounts and signatures, the directory in the browser, richer widget events; Figma plugin compatibility is an owner decision | 8–26 | +| M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); widget fields and property menus (API 4) | a public catalogue, publisher accounts and signatures, the directory in the browser, widget hover and timers; Figma plugin compatibility is an owner decision | 6–22 | | M12 Boards (new) | first slice landed (2026-10-24): board files, sticky notes, shapes with text, connectors that follow, marker, highlighter, eraser, stamps, board toolbar and contextual bar, live sessions; then a shared timer and voting sessions | widgets, templates, tables, mind maps ([boards-parity](boards-parity.md)) | 20–44 | | M13 AI features (new, owner decision) | not started | local models | 30–60 | @@ -77,6 +77,7 @@ blockers. |---|---|---| | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | | 2026-10-24 | minor | M12: a shared board timer and voting sessions; 30–60 h → 26–54 h | +| 2026-10-24 | minor | M11: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`), plugin API 4; 8–26 h → 6–22 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Milestone M12 Boards added; AI features move to M13 | | 2026-10-21 | minor | M6: best-effort `.sketch` import (Sketch 48 and newer), see [file-format-parity](file-format-parity.md). Percentages unchanged | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1e9d3a1f..5c8dcc01 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -21,7 +21,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c | Essentials user | **~59%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.94 × 0.88) | | Mainstream practitioner | **~50%** | estimated ([below](#mainstream-practitioner): weekly areas 72.7% × discounts 0.9 × 0.9 × 0.85) | | Remaining to beta | **~155–280 h** one Opus 5.5 agent (~32–64 h wall-clock with 5 parallel agents) | estimated | -| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | +| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 parallel agents) | estimated | | Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); multiplayer and checking the best-effort `.fig` import on real files are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | | The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were @@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places. | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | +| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) | | Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the experimental `.fig` export opens elsewhere (5–15) | | Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) | @@ -113,7 +113,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 15 | Dev Mode / inspect | 2 | 98% | 78% | Publishing mappings from the code repository; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode, dev resources links, import lines, compare with versions, an inspect-only canvas and layers panel and measurements kept on the canvas landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–2 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | -| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | +| 18 | Plugins & widgets | 4 | 65% | 38% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879), then widget fields and property menus (API 4: a text layer edited on the canvas like any text or with `widget.setText`, its value kept in the widget's state; actions, toggles and colour choices in a bar over the selected widget, `widget.menu`). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets have no hover or timers and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | @@ -134,9 +134,9 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) | | Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech (none reviewed by a native speaker; measured 2026-10-17, the drafts cover 95–99% of the 2,272 current strings), Swedish and Turkish catalogs, CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) | | Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) | -| Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | +| Ecosystem/plugins | 3 | 40% | 6–22 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), text input and a property menu (API 4), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) | | AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) | -| **Ready for real work** | 100 | **~53%** | **~262–519** | | | +| **Ready for real work** | 100 | **~53%** | **~260–515** | | | Dimension hours add up to the full-parity total without double counting: the feature-area rows for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are @@ -517,11 +517,12 @@ Hours in this narrative are the builders' estimates; the tables above supersede a plugin describes a layer of its own, validated and built as a frame with locked layers; clicks on its named parts call the plugin back in a fresh sandbox with the state kept in the layer, one undo step each; `widget.insert` / `widget.click` / `widget.list`; a Counter example), panels of - several plugins side by side and the colour picker on plugin colour fields. Then a plugin + several plugins side by side and the colour picker on plugin colour fields; then widget fields + and property menus (API 4: `widget.setText`, `widget.menu`). Then a plugin directory on `figcraft-server`: bundles published with the server key and checked in full, browsed and installed from Plugins ▸ Manage plugins after a permissions confirmation, downloads checked against their SHA-256, updates by version. *Left:* a public catalogue, publisher - accounts and signatures; richer widget events; the directory in the browser. 8–26 h. + accounts and signatures; widget hover and timers; the directory in the browser. 6–22 h. 9. **Hardening and performance**: *landed:* `cargo xtask bench` (synthetic 1k/10k/50k-layer pages: engine, render and headless UI-frame timings). The canvas keeps its last image and re-renders only what a pan uncovers or an edit touches: moving one layer at 10k layers re-renders @@ -645,6 +646,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | | 2026-10-24 | minor | Boards (area 22, weight 0): a shared timer and voting sessions (M12); ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; weighted numbers unchanged | | 2026-10-24 | minor | Boards (M12): templates (five built in, `board.template`) and tables (`board.table`, `board.tableEdit`); area 22 ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h (outside the weighted sum) | +| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) | | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | | 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged | diff --git a/plugins/examples/counter/figcraft-plugin.json b/plugins/examples/counter/figcraft-plugin.json index a046ebc5..fa83c414 100644 --- a/plugins/examples/counter/figcraft-plugin.json +++ b/plugins/examples/counter/figcraft-plugin.json @@ -1,10 +1,10 @@ { "id": "org.figcraft.examples.counter", "name": "Counter", - "version": "1.0.0", - "description": "A widget that counts on the canvas: click its + and − buttons. The count is kept in the document.", + "version": "1.1.0", + "description": "A widget that counts on the canvas: click its + and − buttons, double-click its label to edit it, and use its menu to reset it, count in tens or colour it. Everything is kept in the document.", "main": "counter.wasm", - "api": 3, + "api": 4, "widget": true, "permissions": { "access": "read" } } diff --git a/plugins/examples/counter/src/lib.rs b/plugins/examples/counter/src/lib.rs index d149a53d..92815c4f 100644 --- a/plugins/examples/counter/src/lib.rs +++ b/plugins/examples/counter/src/lib.rs @@ -1,19 +1,53 @@ -//! Counter: a widget on the canvas with − and + buttons around a count. Each click calls the -//! plugin back with the widget's state; it hands back the new count and the redrawn widget. +//! Counter: a widget on the canvas with − and + buttons around a count, a label you can edit on the +//! canvas, and a property menu (reset, steps of ten, a colour). Each click, edit or menu choice +//! calls the plugin back with the widget's state; it hands back the new state and the redrawn +//! widget. -use figcraft_plugin_sdk::{Input, Result, Value, entry, json, render_widget}; +use figcraft_plugin_sdk::{Input, Result, Value, Widget, entry, json, render_widget}; /// The count stays within ± this. const LIMIT: i64 = 999_999; +/// The colours the menu offers for the count (the first is the default). +const COLORS: [&str; 4] = ["#1E1E1E", "#0D99FF", "#14AE5C", "#F24822"]; +/// Shown in the label field while it's empty. +const PLACEHOLDER: &str = "Add a label"; -/// The count after clicking `target` (none: the widget was just inserted). -fn next(count: i64, target: Option<&str>) -> i64 { - let n = match target { - Some("increment") => count.saturating_add(1), - Some("decrement") => count.saturating_sub(1), - _ => count, - }; - n.clamp(-LIMIT, LIMIT) +/// What the widget shows. +#[derive(Debug, PartialEq)] +struct Counter { + count: i64, + big: bool, + color: String, +} + +impl Counter { + fn from_state(state: &Value) -> Counter { + let color = state.get("color").and_then(Value::as_str).filter(|c| COLORS.contains(c)).unwrap_or(COLORS[0]); + Counter { + count: state.get("count").and_then(Value::as_i64).unwrap_or(0), + big: state.get("big").and_then(Value::as_bool).unwrap_or(false), + color: color.into(), + } + } + + /// After the click, edit or menu choice `w` brings (nothing: the widget was just inserted). + fn next(mut self, w: &Widget) -> Counter { + let step = if self.big { 10 } else { 1 }; + match (w.target.as_deref(), w.menu.as_deref()) { + (Some("increment"), _) => self.count = self.count.saturating_add(step), + (Some("decrement"), _) => self.count = self.count.saturating_sub(step), + (_, Some("reset")) => self.count = 0, + (_, Some("big")) => self.big = w.value.as_bool().unwrap_or(!self.big), + (_, Some("color")) => { + if let Some(c) = w.value.as_str().filter(|c| COLORS.contains(c)) { + self.color = c.into(); + } + } + _ => {} + } + self.count = self.count.clamp(-LIMIT, LIMIT); + self + } } /// A square button that calls back with `target`. @@ -22,21 +56,38 @@ fn button(target: &str, name: &str, label: &str) -> Value { "children": [{"type": "text", "text": label, "size": 16, "bold": true, "color": "#1E1E1E"}]}) } -/// The widget showing `count`. -fn view(count: i64) -> Value { - json!({"type": "frame", "name": "Counter", "layout": "horizontal", "gap": 12, "padding": 12, "radius": 12, +/// The widget showing `c` under `label`. +fn view(c: &Counter, label: &str) -> Value { + let (text, ink) = if label.trim().is_empty() { (PLACEHOLDER, "#B3B3B3") } else { (label, "#757575") }; + let (minus, plus) = if c.big { ("−10", "+10") } else { ("−", "+") }; + json!({"type": "frame", "name": "Counter", "layout": "vertical", "gap": 6, "padding": 12, "radius": 12, "align": "min", "fill": "#FFFFFF", "stroke": "#D9D9D9", "children": [ - button("decrement", "Decrease", "−"), - {"type": "text", "name": "Count", "text": count.to_string(), "size": 24, "bold": true, "color": "#1E1E1E"}, - button("increment", "Increase", "+"), + {"type": "text", "name": "Label", "field": "label", "text": text, "size": 12, "color": ink}, + {"type": "frame", "name": "Row", "layout": "horizontal", "gap": 12, "children": [ + button("decrement", "Decrease", minus), + {"type": "text", "name": "Count", "text": c.count.to_string(), "size": 24, "bold": true, "color": c.color}, + button("increment", "Increase", plus), + ]}, ]}) } +/// The property menu for `c`. +fn menu(c: &Counter) -> Value { + json!([ + {"item": "reset", "label": "Reset", "type": "action"}, + {"item": "big", "label": "Steps of 10", "type": "toggle", "checked": c.big}, + {"item": "color", "label": "Colour", "type": "color", "options": COLORS, "value": c.color}, + ]) +} + fn run(input: Input) -> Result { let w = input.widget.ok_or("Insert the counter from Plugins ▸ Widgets")?; - let count = next(w.state.get("count").and_then(Value::as_i64).unwrap_or(0), w.target.as_deref()); - render_widget(&json!({"state": {"count": count}, "root": view(count)}))?; - Ok(json!({"count": count})) + let c = Counter::from_state(&w.state).next(&w); + // An edited label is in the state already, under its field's name. + let label = w.state.get("label").and_then(Value::as_str).unwrap_or("Counter").to_string(); + let state = json!({"count": c.count, "big": c.big, "color": c.color, "label": label}); + render_widget(&json!({"state": state, "root": view(&c, &label), "menu": menu(&c)}))?; + Ok(json!({"count": c.count})) } entry!(run); @@ -45,15 +96,28 @@ entry!(run); mod tests { use super::*; + fn widget(target: Option<&str>, menu: Option<&str>, value: Value) -> Widget { + Widget { target: target.map(Into::into), menu: menu.map(Into::into), value, ..Widget::default() } + } + #[test] - fn counts_within_its_limit() { - assert_eq!(next(0, None), 0); - assert_eq!(next(4, Some("increment")), 5); - assert_eq!(next(4, Some("decrement")), 3); - assert_eq!(next(LIMIT, Some("increment")), LIMIT); - assert_eq!(next(i64::MIN, Some("decrement")), -LIMIT); - assert_eq!(next(2, Some("other")), 2); - assert_eq!(view(3)["children"][1]["text"], "3"); + fn counts_within_its_limit_and_follows_its_menu() { + let c = |count: i64| Counter { count, big: false, color: COLORS[0].into() }; + let at = |n: i64, t: &str| c(n).next(&widget(Some(t), None, Value::Null)).count; + assert_eq!(c(0).next(&Widget::default()).count, 0); + assert_eq!((at(4, "increment"), at(4, "decrement")), (5, 3)); + assert_eq!((at(LIMIT, "increment"), at(i64::MIN, "decrement"), at(2, "other")), (LIMIT, -LIMIT, 2)); + assert_eq!(c(7).next(&widget(None, Some("reset"), Value::Null)).count, 0); + let big = c(1).next(&widget(None, Some("big"), json!(true))); + assert!(big.big); + assert_eq!(big.next(&widget(Some("increment"), None, Value::Null)).count, 11, "steps of ten"); + assert_eq!(c(1).next(&widget(None, Some("color"), json!("#14AE5C"))).color, "#14AE5C"); + assert_eq!(c(1).next(&widget(None, Some("color"), json!("#123456"))).color, COLORS[0], "only the colours offered"); + assert_eq!(Counter::from_state(&json!({"count": 3, "color": "junk"})), c(3)); + let v = view(&c(3), "Score"); + assert_eq!((v["children"][0]["text"].clone(), v["children"][1]["children"][1]["text"].clone()), (json!("Score"), json!("3"))); + assert_eq!(view(&c(3), " ")["children"][0]["text"], PLACEHOLDER); + assert_eq!(menu(&c(0))[1]["checked"], false); assert!(run(Input::default()).is_err(), "it only runs as a widget"); } } diff --git a/plugins/sdk/src/lib.rs b/plugins/sdk/src/lib.rs index 65f70f8d..be2a3208 100644 --- a/plugins/sdk/src/lib.rs +++ b/plugins/sdk/src/lib.rs @@ -27,6 +27,10 @@ //! each time a part of it named as a `target` is clicked, the plugin is called with //! [`Input::widget`] set (its state and what was clicked) and hands back the new state and the //! layers with [`render_widget`]. Each call starts afresh: the state is all a widget remembers. +//! With API 4 a widget can also have editable text fields (a text layer with a `field` name; an +//! edit calls back with [`Widget::field`] and [`Widget::value`], and the value is already in the +//! state under the field's name) and a property menu (`menu` beside `root`; choosing an item calls +//! back with [`Widget::menu`] and, for a toggle or a colour, [`Widget::value`]). //! //! The SDK is `no_std` (it needs `alloc`); outside WebAssembly every host call fails, so a plugin's //! own logic can still be unit-tested on the host. See `docs/plugins.md` for the ABI underneath. @@ -59,7 +63,7 @@ pub struct Input { pub widget: Option, } -/// A widget being inserted (no `target`) or clicked. +/// A widget being inserted (no `target`, `field` or `menu`), clicked, edited or given a menu item. #[derive(Clone, Debug, Default, PartialEq)] pub struct Widget { /// The widget's layer id. @@ -68,6 +72,13 @@ pub struct Widget { pub state: Value, /// The target clicked. pub target: Option, + /// API 4: the editable field edited. + pub field: Option, + /// API 4: the property menu item chosen. + pub menu: Option, + /// API 4: the field's new text, a toggle's new state (`true`/`false`) or the colour chosen + /// (null for a click or an action). + pub value: Value, } /// A button pressed or a value changed in the plugin's panel. @@ -94,10 +105,17 @@ impl Input { value: e.get("value").cloned().unwrap_or(Value::Null), values: v.get("values").cloned().unwrap_or(Value::Null), }), - widget: v.get("widget").map(|w| Widget { - id: w.get("id").and_then(Value::as_u64).unwrap_or_default(), - state: w.get("state").cloned().unwrap_or_else(|| json!({})), - target: w.get("event").and_then(|e| e.get("target")).and_then(Value::as_str).map(ToString::to_string), + widget: v.get("widget").map(|w| { + let event = |key: &str| w.get("event").and_then(|e| e.get(key)); + let name = |key: &str| event(key).and_then(Value::as_str).map(ToString::to_string); + Widget { + id: w.get("id").and_then(Value::as_u64).unwrap_or_default(), + state: w.get("state").cloned().unwrap_or_else(|| json!({})), + target: name("target"), + field: name("field"), + menu: name("menu"), + value: event("value").cloned().unwrap_or(Value::Null), + } }), }) } @@ -360,5 +378,13 @@ mod tests { .widget .unwrap_or_default(); assert_eq!((w.id, &w.state, w.target.as_deref()), (9, &json!({"n": 1}), Some("inc"))); + let e = + Input::parse(r#"{"widget":{"id":9,"state":{},"event":{"field":"label","value":"Hi"}}}"#).unwrap_or_default().widget.unwrap_or_default(); + assert_eq!((e.field.as_deref(), &e.value, e.target.is_none()), (Some("label"), &json!("Hi"), true)); + let m = Input::parse(r##"{"widget":{"id":9,"state":{},"event":{"menu":"color","value":"#FF0000"}}}"##) + .unwrap_or_default() + .widget + .unwrap_or_default(); + assert_eq!((m.menu.as_deref(), &m.value), (Some("color"), &json!("#FF0000"))); } } From 63d926876ffdb22f0c934a65c8130ba23f73cc7c Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 26 Oct 2026 13:57:24 +0700 Subject: [PATCH 13/15] M10: branches in live sessions (#1367) Branches of a file in a live session: create, edit, review, update from main and merge while the file is in a session. The branch stays with its author and makes ids in their own client space far above the main file's; merging is one synced edit and one undo step; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers is refused with a reason. Branch history entries for creation and updates from main. Fuzz and junk tests extended to export settings, guides and the live case. Docs: gap 17, collaboration parity, ROADMAP, mcp, protocol. --- ROADMAP.md | 3 +- crates/engine/src/branch_live_tests.rs | 240 +++++++++++++++++++ crates/engine/src/branch_tests.rs | 60 ++++- crates/engine/src/branch_versions_tests.rs | 12 +- crates/engine/src/branches.rs | 132 ++++++++-- crates/engine/src/history_behaviour_tests.rs | 6 +- crates/engine/src/lib.rs | 2 + crates/engine/src/live.rs | 28 ++- crates/engine/src/save_format_tests.rs | 2 +- docs/collab-protocol.md | 9 + docs/collaboration-parity.md | 5 +- docs/gaps.md | 28 ++- docs/mcp.md | 20 +- 13 files changed, 502 insertions(+), 45 deletions(-) create mode 100644 crates/engine/src/branch_live_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index d7cd3c10..e403b02d 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-25 · **Change:** minor (plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h | +| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | | 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | diff --git a/crates/engine/src/branch_live_tests.rs b/crates/engine/src/branch_live_tests.rs new file mode 100644 index 00000000..ae1a5c87 --- /dev/null +++ b/crates/engine/src/branch_live_tests.rs @@ -0,0 +1,240 @@ +//! Branches of a file in a live session: the branch stays with its author and makes ids in their +//! own client space; merging is one synced edit (and one step of the author's undo history) that +//! everyone receives; peers' edits on main while the branch is open merge against the branch's own +//! base, and conflicts with them resolve as offline; update from main takes them in; a view link +//! still refuses merging; a branch made outside the session that adds layers is refused instead +//! of being sent for the server to refuse. Plus a fuzz test with random real commands. + +use figcraft_collab::sim::Rng; +use figcraft_doc::ids::space_of; +use figcraft_doc::{Document, NodeId}; +use serde_json::{Map, Value, json}; + +use crate::Session; +use crate::branch_tests::{base_with_lists, random_edit}; +use crate::collab_live_tests::{Room, frame_and_rect}; +use crate::collab_tests::base; +use crate::live::{Access, Event, Role, VIEW_ONLY}; +use crate::live_tests::{info, push, relay, serve_as, session}; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn me(s: &Session) -> u32 { + s.live().client().unwrap().id() +} + +fn version_names(s: &mut Session) -> Vec { + ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect() +} + +/// The conflict keys of branch `id`'s review. +fn conflict_keys(s: &mut Session, id: &str) -> Vec { + let review = ok(s, "branch.review", json!({"id": id})); + review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].as_str().unwrap().to_string()).collect() +} + +#[test] +fn a_branch_merges_into_the_session_as_one_synced_edit() { + let mut room = Room::new(base(), 2, 7); + let (f, r) = frame_and_rect(room.server.document()); + let you = me(room.s(0)); + let id = ok(room.s(0), "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string(); + // On the branch tab: a recolour, a new layer and a new style. + ok(room.s(0), "fill.set", json!({"ids": [r], "color": "#FF0000"})); + let made = NodeId(ok(room.s(0), "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10}))["id"].as_u64().unwrap()); + assert_eq!(space_of(made.0), you, "the branch makes ids in your own client space"); + ok(room.s(0), "style.create", json!({"type": "paint", "name": "Brand", "from": r})); + room.settle(); + assert!(!room.server.document().contains(made), "the branch stays with its author"); + // Someone else edits the main file meanwhile. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Renamed by a peer"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + assert_eq!(ok(room.s(0), "branch.merge", json!({"id": id}))["merged"], true); + room.settle(); + room.check(); + let doc = room.server.document(); + assert!(doc.contains(made), "everyone has the merge"); + assert_eq!(doc.node(f).unwrap().name, "Renamed by a peer", "the peer's edit stays"); + assert!(doc.styles.iter().any(|st| st.name == "Brand")); + assert_ne!(doc.node(r).unwrap().fills, base().node(r).unwrap().fills); + // The merge is one step of the author's own undo history. + ok(room.s(0), "edit.undo", json!({})); + room.settle(); + room.check(); + assert!(!room.server.document().contains(made)); + assert_eq!(room.server.document().node(f).unwrap().name, "Renamed by a peer"); +} + +#[test] +fn conflicts_with_peers_resolve_as_offline() { + let mut room = Room::new(base(), 2, 3); + let (f, r) = frame_and_rect(room.server.document()); + let r2 = room.server.document().node(f).unwrap().children[1]; + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Kept on the branch"}})); + ok(room.s(0), "node.set", json!({"id": r2, "props": {"opacity": 0.5}})); + // A peer deletes the layer the branch edited and sets the same property another way. + ok(room.s(1), "edit.delete", json!({"ids": [r]})); + ok(room.s(1), "node.set", json!({"id": r2, "props": {"opacity": 0.2}})); + room.settle(); + let keys = conflict_keys(room.s(0), &id); + let mut want = vec![format!("{r}/layer"), format!("{r2}/opacity")]; + want.sort(); + let mut got = keys.clone(); + got.sort(); + assert_eq!(got, want); + assert!(room.s(0).execute("branch.merge", &json!({"id": id})).is_err(), "conflicts first"); + // The branch's edits win the deletion, main wins the opacity. + let picks: Map = keys.iter().map(|k| (k.clone(), json!(if k.ends_with("/layer") { "branch" } else { "main" }))).collect(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!(doc.node(r).map(|n| n.name.as_str()), Some("Kept on the branch"), "the deleted layer is back, with the branch's edit"); + assert_eq!(doc.node(r2).unwrap().opacity, 0.2); +} + +#[test] +fn update_from_main_takes_in_peers_edits_and_shows_in_the_branch_history() { + let mut room = Room::new(base(), 2, 5); + let (f, r) = frame_and_rect(room.server.document()); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Branch"}})); + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 1"}})); + room.settle(); + assert_eq!(ok(room.s(0), "branch.updateFromMain", json!({}))["updated"], true); + assert_eq!(room.s(0).doc().unwrap().node(f).unwrap().name, "Peer 1", "the branch tab has the peer's edit"); + assert_eq!(version_names(room.s(0)), ["Branch created", "Updated from main"]); + // The new base is main as it was then: a later peer edit is main's change, not a conflict. + ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 2"}})); + room.settle(); + assert_eq!(conflict_keys(room.s(0), &id), Vec::::new()); + ok(room.s(0), "branch.merge", json!({})); + room.settle(); + room.check(); + let doc = room.server.document(); + assert_eq!((doc.node(f).unwrap().name.as_str(), doc.node(r).unwrap().name.as_str()), ("Peer 2", "Branch")); +} + +#[test] +fn a_view_link_still_refuses_merging() { + let (mut s, script) = session(); + s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap(); + let mut doc = Document::new("Shared"); + let page = doc.first_page().unwrap(); + let card = doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); + let (_server, id, welcome) = serve_as(&doc, Access::View); + let mut you = info(Role::Guest, id); + you.access = Access::View; + push(&script, vec![Event::Welcome { info: you, peers: vec![] }, welcome]); + s.live_poll(); + assert_eq!(s.active_read_only(), Some(VIEW_ONLY)); + // The branch is yours to edit; merging it would edit the file you may only view. + ok(&mut s, "branch.create", json!({})); + ok(&mut s, "node.set", json!({"id": card, "props": {"name": "Mine"}})); + ok(&mut s, "branch.updateFromMain", json!({})); + match s.execute("branch.merge", &json!({})) { + Err(crate::EngineError::Disabled(_, why)) => assert_eq!(why, VIEW_ONLY), + other => panic!("expected a refusal, got {other:?}"), + } + assert!(script.lock().unwrap().msgs.is_empty(), "nothing was sent"); +} + +#[test] +fn a_branch_made_outside_the_session_merges_edits_but_not_new_layers() { + let (mut s, script) = session(); + let r = NodeId(ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap()); + let edits = ok(&mut s, "branch.create", json!({"name": "Edits"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Edited before the session"}})); + ok(&mut s, "doc.switch", json!({"index": 0})); + let adds = ok(&mut s, "branch.create", json!({"name": "Adds"}))["id"].as_str().unwrap().to_string(); + ok(&mut s, "shape.ellipse", json!({"x": 90, "y": 0, "width": 20, "height": 20})); + ok(&mut s, "doc.switch", json!({"index": 0})); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, id, welcome) = serve_as(s.doc().unwrap(), Access::Edit); + push(&script, vec![welcome]); + s.live_poll(); + assert!(s.live().syncing()); + // Its new layer is in another id space: refused here rather than by the server. + match s.execute("branch.merge", &json!({"id": adds})) { + Err(crate::EngineError::Disabled(_, why)) => assert!(why.contains("outside this live session"), "{why}"), + other => panic!("expected a refusal, got {other:?}"), + } + assert_eq!(s.doc().unwrap().node_count(), server.document().node_count(), "nothing merged"); + // Edits of layers everyone has go through. + ok(&mut s, "branch.merge", json!({"id": edits})); + let answers = relay(&script, &mut server, id); + push(&script, answers); + s.live_poll(); + assert_eq!(server.document().node(r).unwrap().name, "Edited before the session"); + assert!(*s.doc().unwrap() == *server.document()); +} + +/// The live fuzz test: three people edit with random real commands (export settings and guides +/// included) while one of them works on a branch; every conflict resolved for a random side, the +/// merge goes through the server as it is and everyone converges on it. +#[test] +fn random_edits_on_a_branch_merge_into_a_live_session() { + let (mut created, mut conflicted) = (0, 0); + for seed in 0..8 { + let mut room = Room::new(base_with_lists(), 3, seed); + let mut rng = Rng::new(seed + 50); + let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string(); + for _ in 0..30 { + let i = rng.below(3); + let (cmd, params) = random_edit(room.s(i).doc().unwrap(), &mut rng, true); + let _ = room.s(i).execute(cmd, ¶ms); + if rng.chance(0.3) { + room.settle(); + } + } + room.settle(); + let keys = conflict_keys(room.s(0), &id); + conflicted += usize::from(!keys.is_empty()); + let picks: Map = keys.into_iter().map(|k| (k, json!(if rng.chance(0.5) { "branch" } else { "main" }))).collect(); + let before = room.server.document().clone(); + ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks})); + let merged = room.s(0).doc().unwrap().clone(); + assert!(merged.validate().is_empty(), "seed {seed}: {:?}", merged.validate()); + created += usize::from(merged.ids().any(|n| !before.contains(n))); + room.settle(); + room.check(); + assert!(*room.server.document() == merged, "seed {seed}: the server took the merge as it is"); + } + assert!(created >= 3 && conflicted >= 2, "{created} merges added layers, {conflicted} had conflicts"); +} + +/// No branch command panics on junk parameters in a live session, from the main file's tab or +/// the branch's, and the session stays in sync. +#[test] +fn branch_commands_survive_junk_params_in_a_live_session() { + let junk = [ + json!({}), + json!(null), + json!([1, 2]), + json!({"id": 5, "name": 1e308, "resolutions": [1]}), + json!({"id": "nope", "resolutions": {"1/layer": "both", "doc/name": 7}}), + json!({"name": "\u{0}".repeat(500), "resolutions": {"x": "branch"}}), + ]; + let mut room = Room::new(base(), 2, 11); + ok(room.s(0), "branch.create", json!({})); + let ids: Vec<&str> = crate::branches::specs().iter().map(|s| s.id).collect(); + for tab in [0, 1] { + for id in &ids { + for j in &junk { + let s = room.s(0); + if s.docs.len() > tab { + let _ = s.execute("doc.switch", &json!({"index": tab})); + } + if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + room.settle(); + } + } + } + room.check(); +} diff --git a/crates/engine/src/branch_tests.rs b/crates/engine/src/branch_tests.rs index 1c2cc769..f0e8a72e 100644 --- a/crates/engine/src/branch_tests.rs +++ b/crates/engine/src/branch_tests.rs @@ -1,9 +1,9 @@ //! Branches through commands: create, edit on both sides, review, resolve, merge as one undo step, //! update from main, archive, saving with the file, and a fuzz test over random real commands. -use figcraft_collab::merge::{Side, plan}; +use figcraft_collab::merge::{Key, Side, plan}; use figcraft_collab::sim::Rng; -use figcraft_collab::{Change, DocProp, Mode, Replica, Target}; +use figcraft_collab::{Change, DocProp, Mode, Prop, Replica, Target}; use figcraft_doc::{Document, NodeId, NodeKind}; use serde_json::{Value, json}; @@ -215,26 +215,68 @@ fn shows(changes: &[Change], doc: &Document) -> Result<(), String> { Ok(()) } -/// The fuzz test: random real commands on a branch and on main. Without conflicts the merge (both -/// ways) equals applying both change sets and shows every change of either side; with conflicts, -/// resolving them all for either side gives a valid document. +/// [`base`] with two export settings and two guides on its first frame (lists that merge item by +/// item). +pub(crate) fn base_with_lists() -> Document { + let mut doc = base(); + let f = doc.node(doc.first_page().unwrap()).unwrap().children[0]; + let n = doc.get_mut(f).unwrap(); + n.exports = ["@1", "@2"].map(|suffix| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }).to_vec(); + n.guides = vec![figcraft_doc::Guide { vertical: true, offset: 10.0 }, figcraft_doc::Guide { vertical: false, offset: 20.0 }]; + doc +} + +/// A random real command: often an edit of a frame's export settings or guides, else +/// [`random_command`]. +pub(crate) fn random_edit(doc: &Document, rng: &mut Rng, with_undo: bool) -> (&'static str, Value) { + if rng.chance(0.6) { + return random_command(doc, rng, with_undo); + } + let frames: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Frame)).map(|n| n.id).collect(); + let f = rng.pick(&frames).map_or(json!(0), |i| json!(i)); + let i = rng.below(3); + match rng.below(6) { + 0 => ("export.add", json!({"ids": [f]})), + 1 => ("export.update", json!({"ids": [f], "index": i, "suffix": format!("@{}", rng.below(9))})), + 2 => ("export.remove", json!({"ids": [f], "index": i})), + 3 => ("guide.add", json!({"frame": f, "vertical": rng.chance(0.5), "offset": rng.below(300)})), + 4 => ("guide.move", json!({"frame": f, "index": i, "offset": rng.below(300)})), + _ => ("guide.remove", json!({"frame": f, "index": i})), + } +} + +/// The fuzz test: random real commands on a branch and on main (export settings and guides +/// included). Without conflicts the merge (both ways) equals applying both change sets and shows +/// every change of either side; with conflicts, resolving them all for either side gives a valid +/// document. #[test] fn random_edits_on_both_sides_merge() { - let (mut disjoint, mut conflicted) = (0, 0); + let (mut disjoint, mut conflicted, mut lists) = (0, 0, 0); for seed in 0..60 { let mut rng = Rng::new(seed); - let start = base(); + let start = base_with_lists(); let mut on_branch = start.clone(); on_branch.set_client_id(start.highest_id_space() + 1); let mut sides = [Session::with_document(on_branch), Session::with_document(start.clone())]; for s in &mut sides { for _ in 0..1 + rng.below(5) { - let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false); + let (cmd, params) = random_edit(s.doc().unwrap(), &mut rng, false); let _ = s.execute(cmd, ¶ms); } } let [b, m] = sides.map(|s| s.doc().unwrap().clone()); let p = plan(&start, &b, &m).unwrap(); + // Export settings or guides of a layer changed on both sides that merged item by item. + let list_writes = |changes: &[Change]| -> std::collections::BTreeSet { + changes + .iter() + .filter_map(|c| match c { + Change::Set { id, prop: prop @ (Prop::Exports | Prop::Guides), .. } => Some(Key::Node(*id, *prop)), + _ => None, + }) + .collect() + }; + lists += list_writes(&p.branch).intersection(&list_writes(&p.main)).filter(|k| !p.conflicts.iter().any(|c| c.key == **k)).count(); if p.conflicts.is_empty() { disjoint += 1; let none = |_: &figcraft_collab::merge::Key| None; @@ -260,5 +302,5 @@ fn random_edits_on_both_sides_merge() { } } } - assert!(disjoint >= 10 && conflicted >= 5, "{disjoint} disjoint, {conflicted} with conflicts"); + assert!(disjoint >= 10 && conflicted >= 5 && lists >= 3, "{disjoint} disjoint, {conflicted} with conflicts, {lists} lists merged item by item"); } diff --git a/crates/engine/src/branch_versions_tests.rs b/crates/engine/src/branch_versions_tests.rs index 30bdf736..30f5134e 100644 --- a/crates/engine/src/branch_versions_tests.rs +++ b/crates/engine/src/branch_versions_tests.rs @@ -35,12 +35,12 @@ fn branched() -> (Session, String, NodeId) { fn versions_saved_on_a_branch_are_the_branchs_own() { let (mut s, _, r) = branched(); let original = layer_name(&s, r); - // A new branch's history starts empty: the main file's versions stay with it. - assert!(names(&mut s).is_empty()); + // A new branch's history starts with its creation: the main file's versions stay with it. + assert_eq!(names(&mut s), ["Branch created"]); ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Before"}})); let v = ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}))["id"].clone(); ok(&mut s, "node.set", json!({"id": r, "props": {"name": "After"}})); - assert_eq!(names(&mut s), ["Branch v1"]); + assert_eq!(names(&mut s), ["Branch created", "Branch v1"]); // Viewing a branch version shows it read-only; going back shows the branch as it is. ok(&mut s, "version.preview", json!({"id": v})); assert_eq!(layer_name(&s, r), "Before"); @@ -77,13 +77,13 @@ fn branch_versions_save_with_the_file_and_survive_merging() { ok(&mut t, "file.open", json!({"path": path})); assert_eq!(names(&mut t), ["Main v1"]); ok(&mut t, "branch.open", json!({"id": id})); - assert_eq!(names(&mut t), ["Branch v1", "Autosave"]); + assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]); // Merging adds a version to the main file's history; the branch keeps its own. ok(&mut t, "branch.merge", json!({})); assert_eq!(names(&mut t), ["Main v1", "Autosave", "Merged “Explore”"]); assert_eq!(layer_name(&t, r), "On the branch, later"); ok(&mut t, "branch.open", json!({"id": id})); - assert_eq!(names(&mut t), ["Branch v1", "Autosave"]); + assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]); std::fs::remove_dir_all(&dir).ok(); } @@ -94,7 +94,7 @@ fn archiving_keeps_the_branch_history() { ok(&mut s, "branch.archive", json!({})); assert_eq!(names(&mut s), ["Main v1"]); ok(&mut s, "branch.open", json!({"id": id})); - assert_eq!(names(&mut s), ["Branch v1"]); + assert_eq!(names(&mut s), ["Branch created", "Branch v1"]); } #[test] diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index 8fdea0fc..a4589180 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -4,6 +4,13 @@ //! both sides with it. Branches are saved in the main file ([`figcraft_format::Branch`]); a //! branch tab's edits and its own version history go into its branch ([`sync`]) and are saved with //! the main file. Merging adds a version to the main file's history; the branch keeps its own. +//! +//! In a live session a branch stays with the person who made it (others don't see it), and +//! merging it is one edit of the session document, which syncs to everyone like any other. The +//! session server takes new layers only in the sender's own id space, so a branch made in the +//! session makes its ids in yours, far above the ids the main file makes there ([`WINDOW`]). +//! Merging a branch made outside the session is refused when it adds layers, styles, variables or +//! comments (their ids are in another space). use std::collections::BTreeMap; use std::sync::Arc; @@ -11,6 +18,7 @@ use std::sync::Arc; pub use figcraft_collab::merge::Side; use figcraft_collab::merge::{self, Conflict, Key, Plan}; use figcraft_collab::{Change, Entry, Prop, Target}; +use figcraft_doc::ids::space_of; use figcraft_doc::{Document, NodeId}; use figcraft_format::Branch; pub use figcraft_format::BranchState; @@ -27,7 +35,7 @@ pub fn specs() -> Vec { spec!( "branch.create", "Create branch", - "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base) → {id, name, index (its tab)}", + "{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base); in a live session it stays yours → {id, name, index (its tab)}", false, create ), @@ -53,14 +61,14 @@ pub fn specs() -> Vec { spec!( "branch.updateFromMain", "Update from main", - "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch; refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch (a version in its history); refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}", false, |s, p| combine(s, p, Side::Main) ), spec!( "branch.merge", "Merge branch", - "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step, then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", + "{id?, resolutions? ({: \"branch\" | \"main\"})} merge the branch into the main file as one undo step (in a live session one edit everyone gets; refused on a view link), then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}", false, |s, p| combine(s, p, Side::Branch) ), @@ -219,11 +227,42 @@ fn disabled(cmd: &str, why: impl Into) -> EngineError { EngineError::Disabled(cmd.into(), why.into()) } -/// shortcut: branches of a file in a live session are refused (its participants' client ids -/// aren't known here, so a branch's id space could clash); allow them once the server hands out -/// branch id spaces. -fn refuse_live(s: &Session, cmd: &str, uid: u64) -> Result<()> { - if s.live_doc(uid) { Err(disabled(cmd, "branches aren't available in a live session")) } else { Ok(()) } +/// shortcut: in a live session a branch makes ids in your own client space starting this far +/// above every id made there on the main file or its other branches, so neither side makes the +/// other's ids before the merge unless one makes 16 million first; have the server hand out +/// branch id spaces if that ever matters. +const WINDOW: u64 = 1 << 24; + +/// Where a new branch of the file in tab `main` starts making ids in your live session's client +/// space `client`: [`WINDOW`] above the main file's and every branch's next id there (`None`: the +/// space is full). +fn live_window(s: &Session, main: u64, client: u32) -> Option { + let m = s.docs.iter().find(|d| d.uid == main)?; + let tabs = s.docs.iter().filter(|d| d.branch_of.as_ref().is_some_and(|(t, _)| *t == main)).map(|d| d.doc.next_id()); + let next = m.branches.iter().map(|b| b.head.next_id()).chain(tabs).chain([m.doc.next_id()]); + let top = next.filter(|n| space_of(n.0) == client).max()?; + let start = top.0.checked_add(WINDOW)?; + (space_of(start) == client).then_some(NodeId(start)) +} + +/// What merging into a live session's document would add that its server won't take from client +/// `client`: a new layer outside your id space (unless it is a layer of a base taken in this +/// session, `in_session`, that main deleted since: the server takes those back), or a new style, +/// variable, collection or comment in another client's space. `None`: everything goes through. +fn foreign(plan: &Plan, base: &Document, main: &Document, merged: &Document, client: u32, in_session: bool) -> Option { + let outside = |n: NodeId| space_of(n.0) != client && !(in_session && base.contains(n)); + if let Some(n) = merged.nodes().find(|n| !main.contains(n.id) && outside(n.id)) { + return Some(format!("layer “{}”", n.name)); + } + plan.changes(Side::Branch).iter().find_map(|c| match c { + Change::Put { entry, .. } + if entry.id_space().is_some_and(|sp| sp != 0 && sp != client) + && plan.base().value(&Target::Entry(entry.clone())).ok().flatten().is_none() => + { + Some(entry.describe()) + } + _ => None, + }) } fn create(s: &mut Session, p: &Value) -> Result { @@ -232,7 +271,7 @@ fn create(s: &mut Session, p: &Value) -> Result { if ds.branch_of.is_some() { return Err(disabled(CMD, "this tab is a branch: make branches from the main file")); } - refuse_live(s, CMD, ds.uid)?; + let live = s.live_client_id(CMD, ds.uid)?; if ds.branches.len() >= figcraft_format::MAX_BRANCHES { return Err(disabled(CMD, format!("the file has the most branches it can keep ({})", figcraft_format::MAX_BRANCHES))); } @@ -240,12 +279,22 @@ fn create(s: &mut Session, p: &Value) -> Result { string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Branch {}", ds.branches.len() + 1)); let name = figcraft_format::clip(&name, figcraft_format::MAX_VERSION_NAME); let base = ds.document_itself().clone(); - // Its own id space, above every space the file, its live sessions and its branches used. - let client = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); - let client = client.checked_add(1).ok_or_else(|| disabled(CMD, "no id space left for a new branch"))?; + let no_space = || disabled(CMD, "no id space left for a new branch"); + // In a live session: your own client space, far above the main file's ids there. Otherwise its + // own id space, above every space the file, its live sessions and its branches used. + let (client, start) = match live { + Some(c) => (c, Some(live_window(s, ds.uid, c).ok_or_else(no_space)?)), + None => { + let top = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0); + (top.checked_add(1).ok_or_else(no_space)?, None) + } + }; let mut head = (*base).clone(); head.name = name.clone(); head.set_client_id(client); + if let Some(start) = start { + head.reserve_ids(NodeId(start.0 - 1)); + } let id = crate::cmd::file::unique_id(|id| ds.branches.iter().any(|b| b.id == id)); let uid = ds.uid; let time = crate::cmd::comment::now_secs(); @@ -259,7 +308,13 @@ fn create(s: &mut Session, p: &Value) -> Result { head: Arc::new(head), versions: Vec::new(), }); + if live.is_some() { + s.live_branch_base(uid, &id); + } let index = open_tab(s, uid, &id)?; + if let Some(tab) = s.docs.get_mut(index) { + crate::cmd::file::add_version(tab, CREATED, "", false); + } Ok(json!({"id": id, "name": name, "index": index})) } @@ -269,11 +324,22 @@ fn open_tab(s: &mut Session, main: u64, id: &str) -> Result { s.active = i; return Ok(i); } - let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; - let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; + let live = s.live_client_id("branch.open", main)?; + let m = s.docs.iter().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; let mut doc = (*b.head).clone(); // A branch read from a file has no client id yet. doc.set_client_id(b.client); + // In your live session, a branch that would make ids right where the main file makes them (one + // read from a file that made none yet) moves up. + if live == Some(b.client) + && doc.next_id().0.abs_diff(m.doc.next_id().0) < WINDOW + && let Some(start) = live_window(s, main, b.client) + { + doc.reserve_ids(NodeId(start.0 - 1)); + } + let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?; + let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?; // The branch's own version history: the tab's versions while it is open ([`sync`]). let mut tab = DocState::new(doc, None).with_versions(b.versions.clone()); tab.branch_of = Some((main, id.to_string())); @@ -340,7 +406,13 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } // The main file itself, not a version shown in its place. m.end_preview(); - refuse_live(s, cmd, main_uid)?; + // Merging edits the session document, which a view link (or a file on its way) can't. + if from == Side::Branch + && let Some(why) = s.live_read_only(main_uid) + { + return Err(disabled(cmd, why)); + } + let live = s.live_client_id(cmd, main_uid)?; let picks = resolutions(cmd, p)?; let (base, head, main) = documents(s, mi, bi).ok_or(EngineError::NoDocument)?; let plan = merge::plan(&base, &head, &main).map_err(|e| EngineError::Other(e.to_string()))?; @@ -351,6 +423,14 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { } let into = if from == Side::Branch { &main } else { &head }; let (mut doc, touched) = plan.merge(into, from, &pick).map_err(|e| EngineError::Other(format!("can't merge: {e}")))?; + if let Some(client) = live.filter(|_| from == Side::Branch) + && let Some(what) = foreign(&plan, &base, &main, &doc, client, s.live_branch_in_session(main_uid, &id)) + { + let why = format!( + "the branch adds {what}, made outside this live session, which the session can't take: merge it once the file has left the session" + ); + return Err(disabled(cmd, why)); + } doc.mark_dirty(touched); normalize(&mut doc, into); let doc = Arc::new(doc); @@ -383,11 +463,33 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result { b.base = main; b.head = doc; } + if live.is_some() { + s.live_branch_base(main_uid, &id); + } + note(s, mi, bi, UPDATED); out["updated"] = json!(true); } Ok(out) } +/// The branch's own history entries for its creation and its updates from main. +const CREATED: &str = "Branch created"; +const UPDATED: &str = "Updated from main"; + +/// Add version `name` to the history of branch `bi` of the file in tab `mi`: its tab's when it is +/// open (it goes into the branch with [`sync`]), else the branch's. +fn note(s: &mut Session, mi: usize, bi: usize, name: &str) { + let Some((uid, id)) = s.docs.get(mi).and_then(|m| Some((m.uid, m.branches.get(bi)?.id.clone()))) else { return }; + if let Some(tab) = tab_of(s, uid, &id).and_then(|t| s.docs.get_mut(t)) { + crate::cmd::file::add_version(tab, name, "", false); + return; + } + let Some(b) = s.docs.get_mut(mi).and_then(|m| m.branches_mut().get_mut(bi)) else { return }; + let mut tab = DocState::new((*b.head).clone(), None).with_versions(std::mem::take(&mut b.versions)); + crate::cmd::file::add_version(&mut tab, name, "", false); + b.versions = tab.versions; +} + /// Show merged `doc` in tab `ds`. fn show(ds: &mut DocState, doc: Arc) { ds.doc = doc; diff --git a/crates/engine/src/history_behaviour_tests.rs b/crates/engine/src/history_behaviour_tests.rs index e20f1bd1..45e0c7bf 100644 --- a/crates/engine/src/history_behaviour_tests.rs +++ b/crates/engine/src/history_behaviour_tests.rs @@ -547,7 +547,11 @@ fn branches_and_the_version_history() { // On the branch tab. // A branch keeps its own version history: a version saved on its tab is listed there only. ex(&mut s, "file.saveVersion", json!({"name": "On the branch"})); - assert_eq!(versions(&mut s).iter().map(|v| v["name"].clone()).collect::>(), [json!("On the branch")], "a branch's own history"); + assert_eq!( + versions(&mut s).iter().map(|v| v["name"].clone()).collect::>(), + [json!("Branch created"), json!("On the branch")], + "a branch's own history" + ); rect(&mut s, 900.0, 900.0, None); ex(&mut s, "doc.switch", json!({"index": 0})); let made_on_main = rect(&mut s, -500.0, 0.0, None); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 64740d14..548f96ed 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1652,6 +1652,8 @@ mod booleans_tests; #[cfg(test)] mod bound_alpha_tests; #[cfg(test)] +mod branch_live_tests; +#[cfg(test)] mod branch_tests; #[cfg(test)] mod branch_versions_tests; diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index cd8a0f03..f33abd1c 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -37,7 +37,7 @@ //! short-lived presence the host passes on to everyone, never stored. A spotlight makes everyone //! follow the person who started it; moving your own view stops following, as with any follow. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::net::IpAddr; use std::sync::Arc; @@ -599,6 +599,9 @@ pub struct Live { pub spotlight: Option, /// You spotlight: everyone follows you. pub spotlighting: bool, + /// The branches of the session's file made or last updated from main in this session: every + /// layer of their base was in it (see [`crate::branches`]). + branch_bases: BTreeSet, } impl Live { @@ -1251,6 +1254,29 @@ impl Session { self.tab().and_then(|uid| self.live_read_only(uid)) } + /// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for + /// `cmd` while the session is on its way: there is no id yet. + pub(crate) fn live_client_id(&self, cmd: &str, uid: u64) -> Result> { + let Some(live) = self.live.doc(uid) else { return Ok(None) }; + match &live.client { + Some(c) => Ok(Some(c.id())), + None if live.status.active() => Err(EngineError::Disabled(cmd.into(), "wait until the file's live session has started".into())), + None => Ok(None), + } + } + + /// Branch `id` of document `uid` got its base from the session document just now. + pub(crate) fn live_branch_base(&mut self, uid: u64, id: &str) { + if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) { + live.branch_bases.insert(id.to_string()); + } + } + + /// Whether branch `id` of document `uid` got its base in the live session it is in now. + pub(crate) fn live_branch_in_session(&self, uid: u64, id: &str) -> bool { + self.live.doc(uid).is_some_and(|l| l.syncing() && l.branch_bases.contains(id)) + } + /// Whether document `uid` is a live session's synced document: its edits sync and its undo /// history is the user's own. pub fn live_doc(&self, uid: u64) -> bool { diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index fc4ca775..37f22ce7 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -252,7 +252,7 @@ fn fig_copy_from_branch_exports_active_document_without_saving_main() { let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap(); assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle")); assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle")); - assert_eq!(persisted.branches[0].versions[0].name, "Branch version"); + assert_eq!(persisted.branches[0].versions.last().unwrap().name, "Branch version"); assert!(!s.ds().unwrap().is_dirty()); assert!(!s.docs[0].is_dirty()); } diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 061fd728..641a7040 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -99,6 +99,15 @@ fit FigCraft's architecture: collide with what a connected client makes, because a client's numbers continue above every id of its space already in the document. Removed entries are part of the saved session state (`SessionState::removed`). +- **Branches** of a file in a session stay with the person who made them; nothing about them goes + over the wire until a merge, which is an ordinary batch from that person (no message or format + change). So that the server takes the layers a branch made, a branch made in the session makes + its ids in its author's own client space, 2^24 above every id made there on the main file and the + author's other branches. Layers of the branch's base that main deleted during the session come + back with their ids (the server accepts re-creating deleted ids). Merging a branch whose new + layers, styles, variables or comments lie in another id space (one made offline or in an earlier + session) is refused by the app before anything is sent, since the server would refuse it (see + `crates/engine/src/branches.rs`). - Objects inside an entry are not separate objects: modes are part of their collection, a variable's values per mode part of the variable and replies part of their comment thread. Two people adding a mode to the same collection, editing different modes of one variable, or replying diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index ae503633..c9af478f 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -18,7 +18,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | -| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 | +| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | | Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 | | Notifications, activity | yes | no | with sharing | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Branches in live sessions (gap 17): creating, editing, reviewing, updating from main and merging a branch work while its file is in a session. The branch stays with its author and makes its ids in their own client space 2^24 above the main file's, so the server takes the layers it adds; merging is one synced edit and one step of the author's undo history; others' edits on main merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers, styles, variables or comments is refused with a reason. A branch's history gets entries for its creation and updates from main. No wire format change. Row wording also brought up to date with #798 (export settings and guides merge item by item). Numbers unchanged | | 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged | | 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged | | 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index 9f95b4f5..8fce3103 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-25 · **Change:** minor (gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); 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-25 · **Change:** minor (gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; 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; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); 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 @@ -30,7 +30,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | | 16 | Dev Mode: publishing mappings from the code repository; the editor extension is left to the MCP server (measurements kept on the canvas and a read-only layers panel landed 2026-10-24) | Feature | 1–2 | [dev-mode-parity](dev-mode-parity.md) | -| 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | +| 17 | Branching: text runs merged whole, branches only their author sees in a live session (branches in live sessions, export settings and guides item by item, and history entries for creation and updates landed 2026-10-24) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | | 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) | @@ -433,9 +433,26 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe different items changed on each side, or items added at the end on one side, merge without a conflict; only the same item changed both ways, an item removed on one side, or different items added on both sides conflict (as the whole list). -- **Missing:** branches of a file in a live session are refused; other lists (text runs, export - settings, guides…) still conflict as a whole; the branch history has no entries of its own for - its creation or updates from main (only saved versions, autosaves and the merge on main). +- **Landed (2026-10-18, #798):** layout grids, export settings, guides, prototype interactions, + accessibility annotations and Dev Mode resources merge item by item like fills (code links merge + whole: one per language). +- **Landed (2026-10-24):** branches of a file in a live session: creating, editing, reviewing, + updating from main and merging work while the file is in a session. A branch stays with the + person who made it; merging it is one edit of the session document that everyone gets, and one + step of its author's own undo history. A branch made in the session makes its ids in its author's + own client space, 2^24 above every id made there on the main file, so the session server takes + the layers it adds; others' edits on main while it is open are main's changes against the + branch's own base, and conflicts with them resolve as offline (a layer a peer deleted comes back + with its id when the branch's edits win). A view link still refuses merging, and merging a branch + made outside the session that adds layers, styles, variables or comments (ids in another space) + is refused with a reason instead of being sent for the server to refuse. A branch's history gets + entries for its creation and each update from main. Engine tests through a session server with + several people, a fuzz test with random real commands (export settings and guides included, + offline and live) and a junk-parameter sweep of the branch commands in a session. +- **Missing:** text runs still conflict as a whole (runs are character ranges into the text, so + lining them up item by item would need a text diff, not positions); branches in a live session + are their author's only (others can't see or open them: that needs the server to keep them); + a branch made offline that adds layers merges only outside a session. - **Estimate:** 1–2 h. ### 18. Display P3 on screen (several windows landed) @@ -507,6 +524,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 2026-10-24 | minor | Gap 24: a shared board timer and voting sessions landed (M12); 30–60 h → 26–54 h, ranking unchanged | | 2026-10-24 | minor | Gap 24: board templates (retrospective, brainstorm, weekly planner, flow chart, kanban board; `board.template`, a toolbar picker) and tables (`board.table`, `board.tableEdit`); 30–60 h → 26–54 h, rank unchanged | | 2026-10-24 | minor | Gap 6: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`, a bar over the selected widget), plugin API 4; 8–26 h → 6–22 h; ranking unchanged | +| 2026-10-24 | minor | Gap 17: branches of a file in a live session (create, edit, review, update from main and merge while the file is in a session; the branch stays with its author and makes ids in their own client space far above the main file's; merging is one synced edit and one step of the author's undo history; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link still refuses merging; a branch made outside the session that adds layers is refused with a reason); a branch's history gets entries for its creation and updates from main; the list-merge wording brought up to date with #798 (export settings and guides already merged item by item); text runs still merge whole. Ranking and hours unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index c630a85f..b930b881 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1168,7 +1168,8 @@ branches). Driven with `run_command`: - `branch.create {name?}` copies the file into a new branch tab and returns `{id, name, index}`. The branch remembers the file as it is now (its base) and makes new ids in its own id space, so - layers, styles and comments made on both sides never clash. + layers, styles and comments made on both sides never clash. Its history starts with a `Branch + created` version. - `branch.list` lists the branches of the file shown (or of the file a branch tab belongs to): `[{id, name, time, state: active|merged|archived, index?}]` (`index`: its open tab). - `branch.open {id}` opens a branch in a tab (or switches to it). Edit it like any document. @@ -1190,15 +1191,26 @@ branches). Driven with `run_command`: conflict key to `"branch"` or `"main"`; the merge is refused while a conflict has none, so neither side's work is dropped without a choice. - `branch.updateFromMain {id?, resolutions?}` brings the main file's changes since the base into the - branch the same way (the new base is the main file as it is now). It is not undoable: the branch - tab's undo history starts over. + branch the same way (the new base is the main file as it is now) and adds an `Updated from main` + version to the branch's history. It is not undoable: the branch tab's undo history starts over. - `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be opened, but not merged). Each branch keeps its own version history: on a branch tab, `file.saveVersion`, `version.autosave` and the `version.*` commands (list, preview, restore, rename, delete, duplicate) act on the branch's versions, which are saved in the file with the branch and stay with it when it is merged -or archived. Branches aren't available in a live session. +or archived. + +In a live session branches work the same way and stay with the person who made them (others +don't see them). A branch made in the session makes its ids in your own client space, far above +the ids the main file makes, so the session server takes them. Everyone's edits on main while the +branch is open are main's changes in the review (against the branch's own base), and conflicts +with them are resolved as offline. `branch.merge` is one edit of the session document: everyone +gets it, and it is one step of your own undo history. It is refused on a view link, and for a +branch made outside the session (or in an earlier one) whose new layers, styles, variables or +comments lie in another id space, which the server would refuse; merge those once the file has +left the session. While a session is still starting, branch commands that need your client id +wait for it. ### Collaboration changes From f89f656d16a948451888efb229fa0cbdfa043eab Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 22:19:46 +0700 Subject: [PATCH 14/15] #1309: the font-manager path test runs on Unix only (its paths aren't absolute on Windows) --- crates/text/src/fontdb_tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 06b260b2..85d1f3a7 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -441,6 +441,8 @@ fn every_platform_has_a_thai_font_in_the_fallbacks() { } } +// The paths are Unix absolute paths (font managers are a macOS thing): on Windows they aren't absolute. +#[cfg(unix)] #[test] fn font_manager_files_outside_the_font_folders_are_indexed_once() { let folders = [std::path::PathBuf::from("/Library/Fonts"), std::path::PathBuf::from("/Users/a/Library/Fonts")]; From 420e704eee7854535c0fa370c0be7d099a7d95e5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 22:25:10 +0700 Subject: [PATCH 15/15] #966: one Templates entry, and the drop hint names .sketch, as main's catalogs do --- crates/ui-egui/locales/ko.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 4406c96b..5a976f97 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1742,7 +1742,7 @@ status = "draft — needs native review" "Dec" = "12월" "Design system starter" = "디자인 시스템 시작 키트" "Draw a frame" = "프레임 그리기" -"Drop a .figcraft, .fig, .json, .svg or image file." = ".figcraft, .fig, .json, .svg 또는 이미지 파일을 놓으세요." +"Drop a .figcraft, .fig, .sketch, .json, .svg or image file." = ".figcraft, .fig, .sketch, .json, .svg 또는 이미지 파일을 놓으세요." "Drop to import" = "놓아서 가져오기" "Drop to open" = "놓아서 열기" "Edited {when}" = "{when} 편집" @@ -1810,7 +1810,6 @@ status = "draft — needs native review" "Sort by {column}" = "{column} 기준 정렬" "Start from a template" = "템플릿으로 시작" "Start sharing with the Share button, then pass on the code it gives" = "공유 버튼으로 공유를 시작한 뒤 표시되는 코드를 전달하세요." -"Templates" = "템플릿" "The destination document is unavailable. Nothing was imported." = "대상 문서를 사용할 수 없습니다. 아무것도 가져오지 않았습니다." "The destination document was closed. Nothing was imported." = "대상 문서가 닫혔습니다. 아무것도 가져오지 않았습니다." "The destination page was removed. Nothing was imported." = "대상 페이지가 삭제되었습니다. 아무것도 가져오지 않았습니다."

2lg;HGEky#6}j^<1U2AE z0Xh!yQAXn!u*!l33+OP3Kd@}&aKW{X9K`CT`No0UkBut5Fr+r$40nEnr)aK<6DR`0 z7?`r!Qk6?t;Mm+FCWeM&;NOhZNhs11D5?>n5>En*&>4A)G<7(aIN=#}9KCOU_#4>- zQ9>Jh9qa}`fP?@)n%@$%{BTd!P?~@Nf5`F2P4`(IGWOX;aA2IY!Kq!V+}_}05bc#S#c5LG!7?|Uo95>A#5(?TX^oXs zU>p?D*v-pa&yQ}5kx5>j8DBn;@p09-`L63;p0QpX%3OKAh12rH5o>%}NfT@P1#IFe zqT6fwDL^s8!2N+A-AMlhb7}?0S1(m0S?LmBr^a$67|Rv*J*bec?~v4;Z-|1rgN-ZN zC*yVph%1Vcs^RlBTmkXB3$aJqo|O{3HOhdQt67}BA>1Wxg1nJF|C75eePKlzQhqB- z4iw5gcBH)E8o{>(g_Pe}Ua@tv6&?zOFE}UmT}A6b(PU{J0A&U6tH`6>`(c;T=|>xD zj5X3W9erQ6(}S1Ed=BkpO`iufXx5}0mS915|%THvDeBX|;gw%1p| z$mclBxA!0(L(Yt@Alrgq9ypx(MdMx6fLa>z{{&7(6Tp<{I7vv-<;4-xFsXWYIZPy{ z&Idv}7|vd!Q4(e^!#^VG)r$VU;4CLLLi$Af1~Os*A1zFoHbZL{1uFYs!DuOrhBbbI zUI01FN9z@+~fmJt(Pi`}_xq%TqaMt)ezmn%3ezWS_vf-*uQT59g4*2s6 zq&{u7xjmi%r$S*Ug*VoBOAJ?lbbQOUtp^LOBV1FO95ZAsE>-zwqiV-&J#RYO&TMXv zNq_-lsOe1twxdZveGIxPtdf`d9jODLL9@1rTG`MSo-e27<(+Rb_w8s7KMVH>YB3<* zURE3LlXcRGZM3Z^&wf$YDQZN+2?mW?dJPj`M`y1genvcf{LS1E`g>mXW7ko_B+xMQqkb6g#ja6`SGipXl!aAg` z&hAC0!)ywUbCS@V-4)yAQh#CY)ro)KdE)TRkEP%$;n*^ z)QvMfDsWfC&iS@3J}k85ux)_?xFfJ3bV);6?v%Bz%Uip1Wo@!V-juG=jPY$5V_WK; z)$shn#iOBrEK`I^I=nSCBGw~ifpy)4s_KQjV^#v=w90T>ka0<_+uLq)=Bm~UZlpy$ zDpcBYyO%PNoi1!&x%$1-ya_J8c~b3TTd?lxP%+|?J>1x^ZN+5faP^sA%$t%eP$2BE z#HbByq@CBeom1~I7f1naVe&(#*y+N)zA{D-ULy~`b|wYSl)58Od7he=XCnxyk!MV8 zu@@d9f`^?j>6L&bqIqAprqM%?KTgD7o6zpA@pe0Ygu|ErfB7*7v@K`RhHOn8xWS=7MGz&3_|fqSK^kYvz(*5q>0M!%M%avhlw32*L!+EySMNb$X4M$F|&O>r6 z<6{_A0730;M^K$2!Ut-#xu7pcW`X>2$-9DZg{?Yso=<(vQj6M^4EtW7$ z0$C$UA$zYc3Jm-Lj*a896(Z&Dj(#UbyjKR6B(O6CSXW+$<{*R%`5wK3BHwgn-faBl z&}~~|VY}aZ7qBcRqgq*=S(5N(ol4j*+Cg^~wRa$&6iQZ~XN(%TdmNbQf`%a1$kX65 z7t}R$0k_8UN=w!0iQagc36j^|2?++yn@#A1l*Kzld@10Bf){1Hh9t24>%OrIw7a~l z#R4~eor*n}7MZuWAyHRr%iH6^Tewm3d_0rlV^f#P)_hPAnKuoluS>HgI^8&Jv(?!} z7QC}uKBKrfta6vJPvo=ae|13xj6xnVbwX#)y{bF?e^`6-xF)Z(fA~gJ+;OSm3T?&G zO{2An$`WmD)v=XvtmCvQRIO605)ms2OKzyPNL{Gf78ND!IM(Sniq%txvuYmKDyguzU>n7pem?yRxPsX z-AH~n1Q1P~q)7p;`icnC{=+H8`$)Up(ehHwavE8xgLu$BmAP$i$<2=xF>4SNJ04c2 ze?2#$e>_D_9X9jAl-jqRE95@h3Nb%CfzrFtXX+|*E^=&$4|!X z83B*WMcGjm)84E4IdAuz^~*}uPd#$`>Wv#y6E&V2ZS|9GEYIm)_}ivZ+;quroB9{d zSKTSfwSKWqy3lb;BR-(F?TOH?4tLZXni0KoQNR{ADQ`*F@io-CRJ=>>im$mH@H}Kk zpbq1Ezuyw2HZp%02W5c2Ps?dvQDOZ=e^DP?zan^r{zuR;v!Bc?2;|}sO%GWi{5bmT zP*JpFA+`e8xpe#R`kwOL&9y%wIR10I$5$5dtU_x|Hx=L9?O0@hto23_&D{EC zF<>|I{LI=Oh#LAy>bJm>PPO0F!sgoZ*0GAB+xzruZbC&!@JS>dKGSrCl)v@22yKBW zPuYAtzn{pw7lH}L_y=Zb%Zb>QpG3yF;rd$<-}=;FD|nD1JC+@{JSw+lB7*Sd1orra?NM0Y4Xt^zYD7|b^Dk+B)&rV)`aM|`Z;0g88}LH%l(!nYZPe%P zNYJM_`*8Z!zh+@{R+T*d8MuVjfJe=KEfXYdzf^1~OLW}U3q$p7yCvNV{Tz2So`z4d z47CZ@%);m_TZ`z%{9xO2|Gw?1F|ATvI9}k`VH%t!?-kMhuK0RD!sQ^(jpfVD#<5~k zM#6;@=y<^%79MsG*3#Ca*}27NO<@us(_w_nSR;9u!-(j3<% zE$qM9Pu;19CDYG2)YtXZDB0O1(&(q2mj@KU9_c-##yKI|^sdtpHJ?T_t(KM~w0%CUYinWAiF`O46`WVzGq+DkvI8?P}e_OKL*h4`H; zU$!mj!f96NpIyDcGTlyU1{ zo%+w^YMfoYAvEGw^JL^%9@6{T7qaXRvkLXDE1FZM?^F+sXfL*8lxVI>9OpHs!o4v@ zCpFS>HaX8m=A4R!B6JHFiW2h|*L8h4971-cj9)B&q~lWpG;uz8=>u{ee9=fDJ(^>e z)VmPZbgvCZXxW=4o2Ie{@O>9bFO4yuI?FB>_u{^E&kc{SuW6w-KasZuhSdhTswFu; z^D_e*52$@`jKAb%?kes{cZ1XK%&oDllja4+kC!)>xwd<05Q5qQNg5->scpaBw~ts}2F>f2&K^+=4K*JxNZ(bwvm7LH z_tKrOF}}-|?#$Ld%BROhryXhda!I1qr_P$&aB1D&u16*`FG&>Xox3I18&#LCwN#32 z*Ir$+j+S>0Jq6T1*@n6CJKD7Tr`p?-1onR@ei=&&#s}A>I%0Dh|2UgaT%Io$_3?xx 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