collective

Browse / github.com/storytold/figcraft / patch

M5: add toggle overlay actions

M5: add toggle overlay actions A toggle-overlay action opens its destination when closed and closes every active copy when open, with one closing animation. Extracted from #1334 for independent owner review. Includes the command, model, editor controls, localization, destination remapping, color conversion and player regressions. Existing anchored OpenOverlay behavior remains intact. .fig export warns when approximating a toggle as a plain overlay. Validation: full cargo xtask ci passed on Windows (formatting, clippy, workspace tests, layers, attribution, localization and WASM). Uses the CI-pinned external craft-fonts

Status
open
Score
0.00
Adopters
0 other operators
Author
Guillergood (imported from pull request #1440, open)
Base
bdb7e846060964510dcef1e49d235d0c74dda30b
Size
+246 −15 in 27 files
Review
pending
Shared
2026-10-28
Id
424c6e9f7e7bd90fadcbd09c

How it went

No other operator has reported using it yet.

Diff

raw · ROADMAP.md crates/doc/src/colors.rs crates/doc/src/lib.rs crates/doc/src/proto.rs crates/engine/src/cmd/proto.rs crates/format/src/fig/export/assets.rs crates/format/src/fig/export/prototype.rs crates/ui-egui/locales/cs.toml crates/ui-egui/locales/de.toml crates/ui-egui/locales/en.toml crates/ui-egui/locales/es.toml crates/ui-egui/locales/fr.toml crates/ui-egui/locales/id.toml crates/ui-egui/locales/ja.toml crates/ui-egui/locales/ko.toml crates/ui-egui/locales/pt-BR.toml crates/ui-egui/locales/ru.toml crates/ui-egui/locales/sv.toml crates/ui-egui/locales/tr.toml crates/ui-egui/locales/zh-Hans.toml crates/ui-egui/src/panels/proto_editor.rs crates/ui-egui/src/panels/proto_panel.rs crates/ui-egui/src/present.rs crates/ui-egui/src/present/actions.rs crates/ui-egui/src/present/behaviour_tests.rs crates/ui-egui/src/present/navigate.rs docs/prototyping-parity.md

diff --git a/ROADMAP.md b/ROADMAP.md--- 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-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (M5: toggle overlays extracted from #1334; duplicate-safe animated closing, independent of layout and portable fonts; colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); 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@@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).  ## Progress log +- **2026-10-27** · M5: toggle overlays extracted from #1334; duplicate-safe animated closing, independent of layout and portable fonts. Numbers unchanged.+ - **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.@@ -348,6 +350,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | M5: toggle overlays extracted from #1334; duplicate-safe animated closing, independent of layout and portable fonts. Numbers unchanged. | | 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs--- a/crates/doc/src/colors.rs+++ b/crates/doc/src/colors.rs@@ -64,7 +64,7 @@ impl Node {         }         for it in &mut self.interactions {             crate::proto::walk_mut(&mut it.actions, &mut |a| {-                if let Action::OpenOverlay { background: Some(c), .. } = a {+                if let Action::OpenOverlay { background: Some(c), .. } | Action::ToggleOverlay { background: Some(c), .. } = a {                     f(c);                 }             });diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs--- a/crates/doc/src/lib.rs+++ b/crates/doc/src/lib.rs@@ -1236,7 +1236,12 @@ impl Document { fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {     use proto::Action::*;     match a {-        Navigate { dest, .. } | ScrollTo { dest, .. } | OpenOverlay { dest, .. } | SwapOverlay { dest } | ChangeTo { dest } => {+        Navigate { dest, .. }+        | ScrollTo { dest, .. }+        | OpenOverlay { dest, .. }+        | SwapOverlay { dest }+        | ToggleOverlay { dest, .. }+        | ChangeTo { dest } => {             if let Some(m) = map.get(dest) {                 *dest = *m;             }diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs--- a/crates/doc/src/proto.rs+++ b/crates/doc/src/proto.rs@@ -586,6 +586,22 @@ pub enum Action {     SwapOverlay {         dest: NodeId,     },+    /// Open the overlay, or close it when it is already open (the toggle of a menu).+    ToggleOverlay {+        dest: NodeId,+        #[serde(default)]+        position: OverlayPosition,+        /// A click outside the overlay closes it (on unless turned off, as new overlays start).+        #[serde(default = "crate::node::yes", alias = "closeOnOutside")]+        close_on_outside: bool,+        /// Colour drawn behind the overlay (over the frame underneath); None = no background.+        #[serde(default)]+        background: Option<figcraft_color::Color>,+        /// Manual position: where the overlay's top-left sits from the top-left of the layer that+        /// opened it (x, y). None = right below that layer.+        #[serde(default, skip_serializing_if = "Option::is_none")]+        offset: Option<[f64; 2]>,+    },     CloseOverlay,     OpenUrl {         url: String,@@ -710,6 +726,7 @@ impl Action {             Action::ScrollTo { .. } => "Scroll to",             Action::OpenOverlay { .. } => "Open overlay",             Action::SwapOverlay { .. } => "Swap overlay",+            Action::ToggleOverlay { .. } => "Toggle overlay",             Action::CloseOverlay => "Close overlay",             Action::OpenUrl { .. } => "Open link",             Action::ChangeTo { .. } => "Change to",@@ -740,6 +757,7 @@ impl Action {             | Action::ScrollTo { dest, .. }             | Action::OpenOverlay { dest, .. }             | Action::SwapOverlay { dest }+            | Action::ToggleOverlay { dest, .. }             | Action::ChangeTo { dest } => Some(*dest),             _ => None,         }diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs--- a/crates/engine/src/cmd/proto.rs+++ b/crates/engine/src/cmd/proto.rs@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {         spec!(             "proto.interaction.add",             "Add interaction",-            "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",+            "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|toggleOverlay (open when closed; close every active copy when open)|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",             true,             add         ),@@ -388,7 +388,7 @@ fn settle(it: &mut Interaction) {     }     it.delay_ms = it.delay_ms.min(MAX_DELAY_MS);     walk_mut(&mut it.actions, &mut |a| {-        if let Action::OpenOverlay { position, offset, .. } = a {+        if let Action::OpenOverlay { position, offset, .. } | Action::ToggleOverlay { position, offset, .. } = a {             *offset =                 offset.filter(|o| *position == OverlayPosition::Manual && o.iter().all(|c| c.is_finite())).map(|o| o.map(|c| c.clamp(-1e5, 1e5)));         }@@ -549,6 +549,18 @@ mod tests {         assert_eq!(*position, OverlayPosition::BottomCenter);         assert!(*close_on_outside);         assert!(background.is_some());+        // The toggle overlay: the same action that opens an overlay, that closes it when it is+        // already open.+        s.execute(+            "proto.interaction.set",+            &json!({"id": a, "index": 0, "action": {"type": "toggleOverlay", "dest": b, "position": "center", "closeOnOutside": true}}),+        )+        .unwrap();+        let it = s.doc().unwrap().node(a).unwrap().interactions[0].clone();+        let Action::ToggleOverlay { dest, position, close_on_outside, .. } = &it.actions[0] else { panic!("{it:?}") };+        assert_eq!(*dest, b);+        assert_eq!(*position, OverlayPosition::Center);+        assert!(*close_on_outside);         // Whole replacement round-trips through serde.         let mut whole = it.clone();         whole.trigger = Trigger::AfterDelay { ms: 800 };diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs--- a/crates/format/src/fig/export/assets.rs+++ b/crates/format/src/fig/export/assets.rs@@ -55,7 +55,16 @@ impl<'d> Exporter<'d> {                 _ => {}             }             for a in n.interactions.iter().flat_map(|it| figcraft_doc::proto::walk(&it.actions)) {-                if let Action::OpenOverlay { dest, position, close_on_outside, background, anchor, .. } = a {+                let settings = match a {+                    Action::OpenOverlay { dest, position, close_on_outside, background, anchor, .. } => {+                        Some((dest, position, close_on_outside, background, *anchor))+                    }+                    Action::ToggleOverlay { dest, position, close_on_outside, background, .. } => {+                        Some((dest, position, close_on_outside, background, figcraft_doc::proto::OverlayAnchor::Screen))+                    }+                    _ => None,+                };+                if let Some((dest, position, close_on_outside, background, anchor)) = settings {                     let position = if !anchor.is_screen() && *position != OverlayPosition::Manual { OverlayPosition::Manual } else { *position };                     let o = Overlay { position, close_on_outside: *close_on_outside, background: *background };                     match self.overlays.get(dest) {diff --git a/crates/format/src/fig/export/prototype.rs b/crates/format/src/fig/export/prototype.rs--- a/crates/format/src/fig/export/prototype.rs+++ b/crates/format/src/fig/export/prototype.rs@@ -106,6 +106,15 @@ impl<'d> Exporter<'d> {                 }                 Some(act)             }+            Action::ToggleOverlay { dest, position, offset, .. } => {+                // `.fig` has no toggle: the overlay opens, and closing it is a separate action.+                self.warn("toggle-overlay prototype actions were written as plain overlays");+                let mut act = node(self, dest, "OVERLAY")?;+                if let (Kv::Object(f), OverlayPosition::Manual, Some([x, y])) = (&mut act, position, offset) {+                    f.push(("overlayRelativePosition", o(vec![("x", fl(*x)), ("y", fl(*y))])));+                }+                Some(act)+            }             Action::SwapOverlay { dest } => node(self, dest, "SWAP"),             Action::ChangeTo { dest } => node(self, dest, "SWAP_STATE"),             Action::ScrollTo { dest, offset } => {diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml--- a/crates/ui-egui/locales/cs.toml+++ b/crates/ui-egui/locales/cs.toml@@ -5,6 +5,7 @@ name = "Čeština" status = "draft — needs native review"  [ui]+"Toggle overlay" = "Přepnout překryv" "% of all commits" = "% všech commitů" "(no longer published)" = "(již nepublikováno)" "({n} warnings: some entries were skipped)" = { one = "({n} varování: některé položky byly přeskočeny)", few = "({n} varování: některé položky byly přeskočeny)", other = "({n} varování: některé položky byly přeskočeny)" }diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml--- a/crates/ui-egui/locales/de.toml+++ b/crates/ui-egui/locales/de.toml@@ -9,6 +9,7 @@ name = "Deutsch" status = "draft — needs native review"  [ui]+"Toggle overlay" = "Overlay umschalten" "% of all commits" = "% aller Commits" "(no longer published)" = "(nicht mehr veröffentlicht)" "({n} warnings: some entries were skipped)" = { one = "({n} Warnung: einige Einträge wurden übersprungen)", other = "({n} Warnungen: einige Einträge wurden übersprungen)" }diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml--- a/crates/ui-egui/locales/en.toml+++ b/crates/ui-egui/locales/en.toml@@ -8,6 +8,7 @@ name = "English"  [ui]+"Toggle overlay" = "Toggle overlay" "% of all commits" = "% of all commits" "(no longer published)" = "(no longer published)" "({n} warnings: some entries were skipped)" = { one = "({n} warning: some entries were skipped)", other = "({n} warnings: some entries were skipped)" }diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml--- a/crates/ui-egui/locales/es.toml+++ b/crates/ui-egui/locales/es.toml@@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review"  [ui]+"Toggle overlay" = "Alternar superposición" "Add a minute" = "Añadir un minuto" "Add column" = "Añadir columna" "Add measurement" = "Añadir medición"diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml--- a/crates/ui-egui/locales/fr.toml+++ b/crates/ui-egui/locales/fr.toml@@ -9,6 +9,7 @@ name = "Français" status = "draft — needs native review"  [ui]+"Toggle overlay" = "Ouvrir/fermer la superposition" "% of all commits" = "% de tous les commits" "(no longer published)" = "(plus publié)" "({n} warnings: some entries were skipped)" = { one = "({n} avertissement : certaines entrées ont été ignorées)", other = "({n} avertissements : certaines entrées ont été ignorées)" }diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml--- a/crates/ui-egui/locales/id.toml+++ b/crates/ui-egui/locales/id.toml@@ -8,6 +8,7 @@ name = "Bahasa Indonesia" status = "draft — needs native review"  [ui]+"Toggle overlay" = "Alihkan overlay" "Relative to" = "Relatif terhadap" "Trigger layer" = "Layer pemicu" "Whole screen" = "Seluruh layar"diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml--- a/crates/ui-egui/locales/ja.toml+++ b/crates/ui-egui/locales/ja.toml@@ -11,6 +11,7 @@ name = "日本語" status = "draft — needs native review"  [ui]+"Toggle overlay" = "オーバーレイを切り替え" "% of all commits" = "全コミット中の %" "(no longer published)" = "(公開終了)" "({n} warnings: some entries were skipped)" = { other = "(警告 {n} 件:一部の項目をスキップ)" }diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml--- a/crates/ui-egui/locales/ko.toml+++ b/crates/ui-egui/locales/ko.toml@@ -10,6 +10,7 @@ name = "한국어" status = "draft — needs native review"  [ui]+"Toggle overlay" = "오버레이 전환" "% of all commits" = "전체 커밋 중 %" "(no longer published)" = "(게시 중단됨)" "({n} warnings: some entries were skipped)" = { other = "(경고 {n}개: 일부 항목을 건너뜀)" }diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml--- a/crates/ui-egui/locales/pt-BR.toml+++ b/crates/ui-egui/locales/pt-BR.toml@@ -8,6 +8,7 @@ name = "Português (Brasil)" status = "draft — needs native review"  [ui]+"Toggle overlay" = "Alternar sobreposição" "% of all commits" = "% de todos os commits" "(no longer published)" = "(não publicado mais)" "({n} warnings: some entries were skipped)" = { one = "({n} aviso: algumas entradas foram ignoradas)", other = "({n} avisos: algumas entradas foram ignoradas)" }diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml--- a/crates/ui-egui/locales/ru.toml+++ b/crates/ui-egui/locales/ru.toml@@ -8,6 +8,7 @@ name = "Русский"  [ui]+"Toggle overlay" = "Переключить оверлей" "% of all commits" = "% всех коммитов" "(no longer published)" = "(публикация снята)" "({n} warnings: some entries were skipped)" = { one = "({n} предупреждение: некоторые записи пропущены)", few = "({n} предупреждения: некоторые записи пропущены)", many = "({n} предупреждений: некоторые записи пропущены)", other = "({n} предупреждения: некоторые записи пропущены)" }diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml--- a/crates/ui-egui/locales/sv.toml+++ b/crates/ui-egui/locales/sv.toml@@ -7,6 +7,7 @@ name = "Svenska"  [ui]+"Toggle overlay" = "Visa/dölj överlägg" "Relative to" = "Relativt till" "Trigger layer" = "Utlösande lager" "Whole screen" = "Hela skärmen"diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml--- a/crates/ui-egui/locales/tr.toml+++ b/crates/ui-egui/locales/tr.toml@@ -10,6 +10,7 @@ name = "Türkçe"  [ui]+"Toggle overlay" = "Bindirmeyi aç/kapat" "% of all commits" = "Tüm commit'lerin %'si" "(no longer published)" = "(artık yayımlanmıyor)" "({n} warnings: some entries were skipped)" = { other = "({n} uyarı: bazı girdiler atlandı)" }diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml--- a/crates/ui-egui/locales/zh-Hans.toml+++ b/crates/ui-egui/locales/zh-Hans.toml@@ -11,6 +11,7 @@ name = "简体中文" status = "draft — needs native review"  [ui]+"Toggle overlay" = "切换浮层" "% of all commits" = "占全部提交的 %" "(no longer published)" = "(已停止发布)" "({n} warnings: some entries were skipped)" = { other = "({n} 条警告:已跳过部分条目)" }diff --git a/crates/ui-egui/src/panels/proto_editor.rs b/crates/ui-egui/src/panels/proto_editor.rs--- a/crates/ui-egui/src/panels/proto_editor.rs+++ b/crates/ui-egui/src/panels/proto_editor.rs@@ -90,20 +90,21 @@ impl Cx<'_> { const INDENT: f32 = 12.0;  /// The kinds an action can be, as its menus list them (a separator before each in `GROUPS`).-const ACTIONS: [&str; 11] = [+const ACTIONS: [&str; 12] = [     tr_noop("Navigate to"),     tr_noop("Change to"),     tr_noop("Back"),     tr_noop("Scroll to"),     tr_noop("Open link"),     tr_noop("Open overlay"),+    tr_noop("Toggle overlay"),     tr_noop("Swap overlay"),     tr_noop("Close overlay"),     tr_noop("Set variable"),     tr_noop("Set variable mode"),     tr_noop("Conditional"), ];-const GROUPS: [usize; 5] = [2, 4, 5, 8, 10];+const GROUPS: [usize; 5] = [2, 4, 5, 9, 11];  /// The action kinds as menu items; the one picked. fn action_menu(ui: &mut Ui) -> Option<&'static str> {diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs--- a/crates/ui-egui/src/panels/proto_panel.rs+++ b/crates/ui-egui/src/panels/proto_panel.rs@@ -260,6 +260,9 @@ pub(crate) fn action_of(label: &str, doc: &Document, page: NodeId, node: NodeId,             offset: None,             anchor: OverlayAnchor::Screen,         },+        "Toggle overlay" => {+            Action::ToggleOverlay { dest: frame_dest?, position: OverlayPosition::Center, close_on_outside: true, background: None, offset: None }+        }         "Swap overlay" => Action::SwapOverlay { dest: frame_dest? },         "Close overlay" => Action::CloseOverlay,         "Set variable" => {@@ -862,6 +865,95 @@ fn dest_dropdown(     }); } +/// The settings of an overlay action: its destination, where it sits, and how it behaves.+#[allow(clippy::too_many_arguments)]+fn overlay_fields(+    app: &mut FigApp,+    ui: &mut Ui,+    doc: &Document,+    frames: &[NodeId],+    slot: &Slot,+    dest: NodeId,+    position: OverlayPosition,+    close_on_outside: bool,+    background: Option<figcraft_color::Color>,+    offset: Option<[f64; 2]>,+) {+    let mk = move |d: NodeId, position, close_on_outside, background, offset| Action::ToggleOverlay {+        dest: d,+        position,+        close_on_outside,+        background,+        offset,+    };+    dest_dropdown(app, ui, doc, frames, Some(dest), tr("Overlay"), move |d| mk(d, position, close_on_outside, background, offset), slot);+    widgets::sublabels(ui, tr("Overlay position"), None);+    row(ui, 32.0, |ui| {+        let resp = widgets::dropdown(ui, tr(position.label()), ui.available_width(), None);+        menu_popup(&resp).show(|ui| {+            for p in OverlayPosition::ALL {+                if menu_item(ui, tr(p.label()), "") {+                    set_action(app, slot, mk(dest, p, close_on_outside, background, offset));+                    ui.close();+                }+            }+        });+    });+    if position == OverlayPosition::Manual {+        // From the top-left of the layer that opens it; empty: right below that layer.+        row(ui, 32.0, |ui| {+            let w = ((ui.available_width() - 6.0) / 2.0).max(36.0);+            for (i, (name, key)) in [("X", "proto_ov_x"), ("Y", "proto_ov_y")].into_iter().enumerate() {+                let shown = offset.and_then(|o| o.get(i).copied());+                // "Auto" is no offset (right below the layer), not 0: the word isn't taken+                // as a value here.+                let Some(e) =+                    widgets::number_field_bindable(ui, slot.id(key), Lead::Text(name), shown, "", w, 1.0, None, widgets::Empty::Text(tr("Auto"))).0+                else {+                    continue;+                };+                let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = e.settled() else { continue };+                if v.is_finite() {+                    let mut o = offset.unwrap_or([0.0, 0.0]);+                    if let Some(slot_v) = o.get_mut(i) {+                        *slot_v = v.clamp(-100_000.0, 100_000.0).round();+                    }+                    set_action(app, slot, mk(dest, position, close_on_outside, background, Some(o)));+                }+            }+        });+    }+    row(ui, 28.0, |ui| {+        if widgets::checkbox(ui, close_on_outside, tr("Close when clicking outside")) {+            set_action(app, slot, mk(dest, position, !close_on_outside, background, offset));+        }+    });+    row(ui, 28.0, |ui| {+        if widgets::checkbox(ui, background.is_some(), tr("Add background behind overlay")) {+            let bg = if background.is_some() { None } else { Some(figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25)) };+            set_action(app, slot, mk(dest, position, close_on_outside, bg, offset));+        }+    });+    if let Some(bg) = background {+        row(ui, 32.0, |ui| {+            widgets::swatch(ui, &Paint::solid(bg), 16.0);+            if let Some(h) = widgets::text_field(ui, slot.id("proto_ovbg"), &bg.to_hex(), 96.0, "")+                && let Some(c) = figcraft_color::Color::from_hex_or_name(&h)+            {+                set_action(app, slot, mk(dest, position, close_on_outside, Some(c), offset));+            }+            let alpha = (bg.a * 100.0).round();+            if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =+                widgets::number_field(ui, slot.id("proto_ovbg_a"), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0)+                && v.is_finite()+            {+                let a = v.clamp(0.0, 100.0) as f32 / 100.0;+                set_action(app, slot, mk(dest, position, close_on_outside, Some(bg.with_alpha(a)), offset));+            }+        });+    }+}+ /// The settings of the action at `slot` (set variable and conditional actions have their own /// editors in `proto_editor`). pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, slot: &Slot, action: &Action) {@@ -917,6 +1009,9 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:                 }             });         }+        Action::ToggleOverlay { dest, position, close_on_outside, background, offset } => {+            overlay_fields(app, ui, doc, &frames, slot, *dest, *position, *close_on_outside, *background, *offset);+        }         Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor } => {             let (dest, position, close_on_outside, background, offset, anchor) = (*dest, *position, *close_on_outside, *background, *offset, *anchor);             let mk = move |d: NodeId| Action::OpenOverlay { dest: d, position, close_on_outside, background, offset, anchor };diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs--- a/crates/ui-egui/src/present.rs+++ b/crates/ui-egui/src/present.rs@@ -396,6 +396,15 @@ impl Presenter {         self.drag_scroll = None;     } +    /// An overlay leaving: it animates out with its opening transition reversed. One closed+    /// mid-opening reverses from where it got to.+    fn closing_push(&mut self, o: OverlayState, now: f64) {+        if !matches!(o.transition, Transition::Instant) && o.dur > 0.0 {+            let shown = ((now - o.opened) / o.dur).clamp(0.0, 1.0);+            self.closing.push((now - (1.0 - shown) * o.dur, o));+        }+    }+     /// Size the prototype another way; the view starts over at its top.     pub fn set_scale(&mut self, scale: PresentScale) {         self.view.scale = scale;diff --git a/crates/ui-egui/src/present/actions.rs b/crates/ui-egui/src/present/actions.rs--- a/crates/ui-egui/src/present/actions.rs+++ b/crates/ui-egui/src/present/actions.rs@@ -131,6 +131,32 @@ impl Presenter {                 self.hovered.clear();                 true             }+            Action::ToggleOverlay { dest, position, close_on_outside, background, offset } => {+                let at = self.page_rect(rect);+                if !doc.contains(*dest) {+                    return false;+                }+                // The toggle: close the overlay when it is already open, else open it.+                if self.overlays.iter().any(|o| o.id == *dest) {+                    return self.close_overlay_id(*dest, now);+                }+                self.delays_fired.retain(|(id, _)| !doc.is_ancestor(*dest, *id));+                self.overlays.push(OverlayState {+                    id: *dest,+                    position: *position,+                    close_on_outside: *close_on_outside,+                    background: *background,+                    anchor: at,+                    anchor_to: OverlayAnchor::Screen,+                    offset: *offset,+                    opened: now,+                    transition: tr,+                    dur,+                    easing,+                });+                self.hovered.clear();+                true+            }             Action::SwapOverlay { dest } => {                 let at = self.page_rect(rect);                 if !doc.contains(*dest) {diff --git a/crates/ui-egui/src/present/behaviour_tests.rs b/crates/ui-egui/src/present/behaviour_tests.rs--- a/crates/ui-egui/src/present/behaviour_tests.rs+++ b/crates/ui-egui/src/present/behaviour_tests.rs@@ -303,6 +303,28 @@ fn an_overlays_after_delay_counts_from_when_it_opens() {     assert_eq!(due(&p, 10.5).map(|x| x.1), Some(0.3)); } +#[test]+fn a_toggle_overlay_opens_and_closes_the_same_overlay() {+    let (doc, [a, b, m1, m2, ..]) = screens();+    let mut p = player(doc, a);+    let shown = |p: &Presenter| p.overlays.iter().map(|o| o.id).collect::<Vec<_>>();+    let toggle = |dest| Action::ToggleOverlay { dest, position: OverlayPosition::Center, close_on_outside: true, background: None, offset: None };+    // The toggle: open when it is closed, close when it is open.+    assert!(act(&mut p, toggle(m1), 0.0));+    assert_eq!(shown(&p), vec![m1], "the first toggle opens the overlay");+    assert!(act(&mut p, toggle(m1), 1.0));+    assert_eq!(shown(&p), vec![], "the same toggle closes it again");+    // A toggle of an overlay that is not the top one closes that one, leaving the rest.+    assert!(act(&mut p, open(m1, true), 2.0));+    assert!(act(&mut p, open(m2, true), 3.0));+    assert_eq!(shown(&p), vec![m1, m2]);+    assert!(act(&mut p, toggle(m1), 4.0));+    assert_eq!(shown(&p), vec![m2], "the toggled overlay closes; the other stays");+    // A toggle of an overlay that is not open opens it.+    assert!(act(&mut p, toggle(b), 5.0));+    assert_eq!(shown(&p), vec![m2, b]);+}+ #[test] fn back_plays_the_way_in_backwards_and_restart_starts_over() {     let (doc, [a, b, c, ..]) = screens();diff --git a/crates/ui-egui/src/present/navigate.rs b/crates/ui-egui/src/present/navigate.rs--- a/crates/ui-egui/src/present/navigate.rs+++ b/crates/ui-egui/src/present/navigate.rs@@ -5,15 +5,22 @@ impl Presenter {     /// Close the top overlay; it animates out with its opening transition reversed.     pub(super) fn close_overlay(&mut self, now: f64) -> bool {         let Some(o) = self.overlays.pop() else { return false };-        if !matches!(o.transition, Transition::Instant) && o.dur > 0.0 {-            // An overlay closed mid-opening reverses from where it got to.-            let shown = ((now - o.opened) / o.dur).clamp(0.0, 1.0);-            self.closing.push((now - (1.0 - shown) * o.dur, o));-        }+        self.closing_push(o, now);+        self.hovered.clear();+        true+    }++    /// Close every active overlay showing `id`; animate out only its topmost (visible) copy.+    pub(super) fn close_overlay_id(&mut self, id: NodeId, now: f64) -> bool {+        let Some(at) = self.overlays.iter().rposition(|o| o.id == id) else { return false };+        let o = self.overlays.remove(at);+        self.overlays.retain(|other| other.id != id);+        self.closing_push(o, now);         self.hovered.clear();         true     } +     /// Follow the editor's document: copy it the first time and whenever it changed, keeping the     /// screen, the overlays and the scroll offsets that still exist (a deleted screen goes back to     /// the start). Runtime state (variables set, variants changed) starts over from the edit.diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md--- a/docs/prototyping-parity.md+++ b/docs/prototyping-parity.md@@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** patch (overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-27 · **Change:** minor (M5: toggle overlays extracted from #1334; duplicate-safe animated closing, independent of layout and portable fonts; overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)  **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons,@@ -16,7 +16,7 @@ larger real prototypes. Remaining: 2–5 h. | Group | Figma | FigCraft | Missing | |---|---|---|---| | Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers; controllers on macOS, Linux and FreeBSD |-| Actions | Navigate to, change to, back, scroll to, open link, open / swap / close overlay, set variable, set variable mode, conditional, play/pause/mute/seek media | 11: Navigate, Back, ScrollTo (with an offset), OpenOverlay, SwapOverlay, CloseOverlay, OpenUrl, ChangeTo, SetVariable, SetVariableMode, Conditional | media actions |+| Actions | Navigate to, change to, back, scroll to, open link, open / swap / close overlay, set variable, set variable mode, conditional, play/pause/mute/seek media | 12: Navigate, Back, ScrollTo (with an offset), OpenOverlay, SwapOverlay, ToggleOverlay, CloseOverlay, OpenUrl, ChangeTo, SetVariable, SetVariableMode, Conditional | media actions | | Transitions | Instant, dissolve, smart animate, move in/out, push, slide in/out | all 8 | — | | Easing | Linear, ease in/out/in-and-out, back variants, gentle, quick, bouncy, slow, custom bezier, custom spring | all 13 (springs physical) | — | @@ -84,6 +84,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr | Expressions (`count + 1`, `mode(Theme) == "Dark"`, `accent[Dark]`) | done | checked when entered; `proto.evaluate` computes one where a layer's interaction would run (MCP and agents) | | Flows, starting points, descriptions | done | | | Overlays: position, close on click outside, background, animate out, stacking, timers from when they open | done | |+| Toggle overlay: the same action opens and closes the overlay | done | | | Scroll behaviour: overflow directions, fixed (drawn on top), sticky, preserve scroll position, scroll to with an offset, momentum and overscroll | done | | | Key combinations (`Shift+K`), recorded in the editor and imported from REST files | done | | | Controller buttons on key/gamepad triggers (Controller menu, REST import, `ui.input {gamepad}` on the control channel) | done | read from controllers on Windows (Windows.Gaming.Input) and in the browser (Gamepad API); macOS, Linux and FreeBSD don't read controllers yet; button names follow one layout (no PlayStation or Switch labels) |@@ -105,6 +106,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr  | Date | Change | Summary | |---|---|---|+| 2026-10-27 | minor | M5: toggle overlays extracted from #1334; duplicate-safe animated closing, independent of layout and portable fonts. Numbers unchanged. | | 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present/delay_tests.rs`). Numbers unchanged | | 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged | | 2026-10-23 | patch | A top-level frame taller (or wider) than the device's screen scrolls with no overflow setting, as Figma's documentation says; before, "No scrolling" frames such as the landing page template's Phone frame only clipped. Content past a frame's own edges still scrolls only as its overflow allows. Checked in the app on macOS. Numbers unchanged |