fix(present): prototype links open only behind the notice or a viewer's click (#1096)

An Open link action carries a URL from the document and fires from whatever
trigger the prototype sets, so an unattended presentation could open web links
and mail messages one after another: a mail link bypassed the leaving-the-
prototype notice entirely, and a skipped notice let any trigger open any
number of links.

- finish() routes every openable URL, mail included, through the notice.
- Skipping the notice ("Don't show this again") opens at once only a link the
  viewer's own click, press, drag or key fired, one per frame. A delay, the
  pointer passing over a layer (enter, leave, hover) and a timed mouse
  trigger that fires after the gesture is over still show the notice, which
  then offers no checkbox: it couldn't skip that notice anyway.
- The player tags each queued link with whether the viewer fired it
  (Presenter::fire_from); an OpenUrl outside an interaction defaults to not.
- The notice covers mail links (new locale string, mail wording).
- Engine prefs/command doc text follows the rule; behavior untouched there.
- present_links_tests: shared setup helpers for the link tests; new
  tests for the skip rule (delay, hover, delayed mouse-up, key, one link per
  click, checkbox only for clicked links); the scheme-filter test now clicks
  with the notice skipped so the filter alone keeps those URLs out.
- docs/prototyping-parity.md row, status line and revision history updated.
This commit is contained in:
Christopher Linnett
2026-10-29 08:11:13 +08:00
parent 2af5e232d5
commit 53f47706ec
8 changed files with 248 additions and 55 deletions
+1 -1
View File
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.linkNotice",
"Notice before leaving for a link",
"{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}",
"{show? (default true; false skips it)} whether a presented prototype's Open link the viewer clicked (or fired by a key) first says that it leaves the prototype, with Open link and Cancel (a link a delay or hover fired always does); kept with the preferences → {shown}",
false,
|s, p| {
let show = boolean(p, "show").unwrap_or(true);
+4 -2
View File
@@ -82,8 +82,10 @@ pub struct Prefs {
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
/// again).
pub getting_started_dismissed: bool,
/// A prototype's Open link goes to a website without first telling the viewer that it leaves
/// the prototype (the notice's "Don't show this again"; `proto.linkNotice` shows it again).
/// A prototype's Open link — to a website or to the mail app — that the viewer's own click,
/// press, drag or key fired opens without first telling them that it leaves the prototype
/// (the notice's "Don't show this again"; `proto.linkNotice` shows it again). A link a delay
/// or the pointer passing over a layer fired still shows the notice (#1096).
pub link_notice_skipped: bool,
/// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards
/// with the picture each file carries) or `"list"` (a table). Missing: the grid.
+1
View File
@@ -1487,6 +1487,7 @@ name = "English"
"This frame is new on the branch." = "This frame is new on the branch."
"This frame isn't on the branch." = "This frame isn't on the branch."
"This link opens a website outside the prototype:" = "This link opens a website outside the prototype:"
"This link sends a mail message through your mail app:" = "This link sends a mail message through your mail app:"
"This server is open to the public: no key needed." = "This server is open to the public: no key needed."
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."
"This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced."
+8 -4
View File
@@ -299,10 +299,14 @@ pub struct Presenter {
clock: f64,
/// The layer the Inspect panel shows (the last one under the pointer).
inspected: Option<NodeId>,
urls: Vec<String>,
/// A link to a website waiting for the viewer to open it ([`link_notice`]), and whether "Don't
/// show this again" is ticked.
link_notice: Option<(String, bool)>,
/// The links the last frame's actions opened, and whether the viewer's own click, press, drag
/// or key fired each; the frame-end queues them behind the notice, but for one the viewer
/// fired while choosing to skip the notice (#1096).
urls: Vec<(String, bool)>,
/// A link waiting for the viewer to open it ([`link_notice`]), and whether "Don't show this
/// again" is ticked; `None` when the notice doesn't offer it (the viewer didn't fire the link,
/// so skipping the notice wouldn't cover it, or they skip it already).
link_notice: Option<(String, Option<bool>)>,
/// The editor's document as last copied (the player copies it again when it changes).
source: Option<Arc<Document>>,
/// Scrolling by dragging: what it scrolls and its offset when the drag began.
+23 -4
View File
@@ -36,14 +36,23 @@ impl Presenter {
self.waiting.retain(|w| w.root == root);
let mut did = false;
for w in due.into_iter().filter(|w| w.root == root) {
// An earlier one may have left the screen: the rest are dropped with it.
did = did || self.fire(w.node, w.k, w.rect, now);
// An earlier one may have left the screen: the rest are dropped with it. The gesture
// is long over: what these open is not the viewer's doing (#1096).
did = did || self.fire_from(w.node, w.k, w.rect, now, false);
}
did
}
/// Run the actions of interaction `k` on `node`. `rect` is the layer's screen rect.
pub(crate) fn fire(&mut self, node: NodeId, k: usize, rect: Option<Rect>, now: f64) -> bool {
self.fire_from(node, k, rect, now, true)
}
/// [`fire`](Self::fire); `live`: the viewer's input fired it just now (not a delay it waited
/// out). The links it opens are the viewer's own only when live from a click, press, drag or
/// key: a delay, the pointer passing over a layer and a timed mouse trigger fire without the
/// viewer asking for anything ([`Presenter::urls`], #1096).
fn fire_from(&mut self, node: NodeId, k: usize, rect: Option<Rect>, now: f64, live: bool) -> bool {
let Some(it) = self.doc.as_ref().and_then(|d| d.node(node)).and_then(|n| n.interactions.get(k)).cloned() else { return false };
let dur = it.easing.seconds(it.duration_ms);
let temp = match it.trigger {
@@ -53,9 +62,18 @@ impl Presenter {
};
let snap = if temp.is_some() && self.temp.is_none() { self.snapshot() } else { None };
let mut did = false;
let first_link = self.urls.len();
for a in &it.actions {
did |= self.perform(a, node, it.transition, dur, it.easing, rect, now);
}
let asked = live
&& matches!(
it.trigger,
Trigger::OnClick | Trigger::OnPress | Trigger::OnDrag | Trigger::MouseDown | Trigger::MouseUp | Trigger::KeyPress { .. }
);
for link in self.urls.iter_mut().skip(first_link) {
link.1 = asked;
}
if did && let (Some(press), Some(snap), Some(rect)) = (temp, snap, rect) {
self.temp = Some(Temp { rect, press, snap, transition: it.transition, dur, easing: it.easing });
}
@@ -178,9 +196,10 @@ impl Presenter {
Action::CloseOverlay => self.close_overlay(now),
Action::OpenUrl { url } => {
// The URL comes from the document and this action fires from whatever trigger the
// prototype sets, including a delay, so no click is implied: only a web link opens.
// prototype sets, including a delay, so no click is implied: only a web or mail
// link is kept, as not the viewer's doing until `fire_from` says otherwise.
if figcraft_engine::doc_urls::openable(url) {
self.urls.push(url.clone());
self.urls.push((url.clone(), false));
}
true
}
+26 -13
View File
@@ -239,15 +239,20 @@ fn poll_pads(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context) {
crate::repaint_in(ctx, PAD_POLL_SECS);
}
/// After a player's frame: the links it opens and the widget clicks it took. A link to a website
/// waits in [`link_notice`] unless the viewer chose to skip the notice; a second link to a website
/// that fires while the notice shows is dropped.
/// After a player's frame: the links it opens and the widget clicks it took. Every link waits in
/// [`link_notice`] — to a website or to the mail app (#1096). Skipping the notice opens at once
/// only a link the viewer's own click, press, drag or key fired, one per frame: a delay or the
/// pointer passing over a layer never opens anything unasked. A further link that fires while the
/// notice shows is dropped.
fn finish(app: &mut FigApp, p: &mut Presenter) {
for u in p.urls.drain(..) {
if app.session.prefs.link_notice_skipped || !goes_to_a_website(&u) {
let skipped = app.session.prefs.link_notice_skipped;
let mut opened = false;
for (u, asked) in p.urls.drain(..) {
if skipped && asked && !opened {
app.services.open_url(&u);
opened = true;
} else if p.link_notice.is_none() {
p.link_notice = Some((u, false));
p.link_notice = Some((u, (asked && !skipped).then_some(false)));
}
}
if let Some(e) = p.click_widgets(&app.session) {
@@ -255,24 +260,32 @@ fn finish(app: &mut FigApp, p: &mut Presenter) {
}
}
/// Whether `url` is an `http` or `https` link (a `mailto:` link opens the mail app instead).
/// Whether `url` is an `http` or `https` link (of the openable URLs, the others are `mailto:`;
/// [`figcraft_engine::doc_urls`]).
fn goes_to_a_website(url: &str) -> bool {
url.trim().split_once(':').is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
}
/// The notice before a link leaves the prototype for a website: the link, "Don't show this
/// again" (saved with the preferences when Open link is pressed), Open link and Cancel. Escape or
/// the close button cancels.
/// The notice before a link leaves the prototype — for a website, or for the mail app: the link,
/// "Don't show this again" for a link the viewer fired (saved with the preferences when Open link
/// is pressed), Open link and Cancel. Escape or the close button cancels.
fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id) {
let Some((url, skip)) = p.link_notice.as_mut() else { return };
let t = Tokens::get(ctx);
let (mut open, mut go, mut cancel) = (true, false, false);
crate::dialogs::window(tr("Leaving the prototype"), ctx).id(id).open(&mut open).show(ctx, |ui| {
ui.set_width(340.0);
widgets::label(ui, tr("This link opens a website outside the prototype:"), t.text);
let what = if goes_to_a_website(url) {
tr("This link opens a website outside the prototype:")
} else {
tr("This link sends a mail message through your mail app:")
};
widgets::label(ui, what, t.text);
ui.add(egui::Label::new(egui::RichText::new(url.as_str()).font(regular(11.0)).color(t.text_secondary)).wrap());
ui.add_space(8.0);
if widgets::checkbox(ui, *skip, tr("Don't show this again")) {
if let Some(skip) = skip.as_mut()
&& widgets::checkbox(ui, *skip, tr("Don't show this again"))
{
*skip = !*skip;
}
ui.add_space(12.0);
@@ -284,7 +297,7 @@ fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id)
cancel |= !open || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if go {
let Some((url, skip)) = p.link_notice.take() else { return };
if skip {
if skip == Some(true) {
app.run("proto.linkNotice", json!({"show": false}));
app.services.save_prefs(&app.session.prefs);
}
+182 -29
View File
@@ -1,9 +1,13 @@
//! A prototype's Open link action carries a URL from the document and fires from whatever trigger
//! the prototype sets, including a delay, so the player hands the opener only a web link
//! ([`figcraft_engine::doc_urls`]). Without that, a saved document opens `javascript:` in the web
//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. A link to a
//! website then waits behind a notice that it leaves the prototype, unless the viewer chose to skip
//! the notice.
//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. Every
//! openable link waits behind a notice that it leaves the prototype — to a website or to the
//! mail app (#1096). Skipping the notice opens at once only a link the viewer's own click, press,
//! drag or key fired, one per frame; a delay or the pointer passing over a layer never opens
//! anything unasked. Before, a mail link opened at once however it was fired, and with the notice
//! skipped any trigger opened any number of links: an unattended presentation could open links and
//! mail messages one after another.
use std::sync::{Arc, Mutex};
@@ -35,46 +39,76 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
/// A presented frame whose layer opens `url` 100ms after the screen appears, with no click
/// anywhere, and with the notice skipped or not. Returns the harness and what the player opened.
/// Frame A presented by `cmd` (`proto.present` or `proto.preview`) with the notice skipped or
/// not, its `interaction` (a trigger and maybe `delayMs`) opening each of `urls`. Returns the
/// harness and what the player opened.
fn playing(cmd: &str, skip_notice: bool, interaction: Value, urls: &[&str]) -> (Harness, Opened) {
let mut s = Session::new();
s.prefs.link_notice_skipped = skip_notice;
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let mut it = interaction;
it["id"] = json!(f);
it["actions"] = urls.iter().map(|u| json!({"type": "openUrl", "url": u})).collect();
it["transition"] = json!({"type": "instant"});
ex(&mut s, "proto.interaction.add", it);
present(cmd, s, f)
}
/// `s` presented by `cmd` from frame `start`, with the opener and the preferences recorded.
fn present(cmd: &str, s: Session, start: figcraft_doc::NodeId) -> (Harness, Opened) {
let seen = Opened::default();
let mut u = Harness::new(s);
u.app.services = Box::new(Opened(seen.0.clone(), seen.1.clone()));
u.ctx.enable_accesskit();
u.run(cmd, json!({"start": start}));
u.frames(2, Vec::new);
(u, seen)
}
/// A presented frame that opens `url` 100ms after the screen appears, with no click anywhere,
/// and with the notice skipped or not. Returns the harness and what the player opened.
fn presented(url: &str, skip_notice: bool) -> (Harness, Opened) {
played("proto.present", url, skip_notice)
}
/// [`presented`], played by `cmd` (`proto.present` or `proto.preview`).
fn played(cmd: &str, url: &str, skip_notice: bool) -> (Harness, Opened) {
let mut s = Session::new();
s.prefs.link_notice_skipped = skip_notice;
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f})));
ex(
&mut s,
"proto.interaction.add",
json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": {"type": "openUrl", "url": url}, "transition": {"type": "instant"}}),
);
let seen = Opened::default();
let mut u = Harness::new(s);
u.app.services = Box::new(Opened(seen.0.clone(), seen.1.clone()));
u.ctx.enable_accesskit();
u.run(cmd, json!({"start": f}));
let (mut u, seen) = playing(cmd, skip_notice, json!({"trigger": {"type": "afterDelay", "ms": 100}}), &[url]);
// Well past the delay.
u.frames(40, Vec::new);
(u, seen)
}
/// A click on the presented frame, and a few frames for what it fires.
fn click_the_frame(u: &mut Harness) {
let at = egui::pos2(700.0, 290.0);
u.button(at, true);
u.button(at, false);
u.frames(3, Vec::new);
}
/// What a click on a frame whose click opens `url` opens, with the notice skipped.
fn opens_on_a_click(url: &str) -> Vec<String> {
let (mut u, seen) = playing("proto.present", true, json!({"trigger": {"type": "onClick"}}), &[url]);
click_the_frame(&mut u);
opened(&seen)
}
fn opened(seen: &Opened) -> Vec<String> {
seen.0.lock().map(|v| v.clone()).unwrap_or_default()
}
fn opens_after_a_delay(url: &str) -> Vec<String> {
opened(&presented(url, true).1)
}
/// The URL the presenter's notice waits to open.
fn notice(u: &Harness) -> Value {
u.app.inspect()["presenter"]["linkNotice"].clone()
}
/// The labels of the controls drawn now.
fn labels(u: &mut Harness) -> Vec<String> {
let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
tree.into_iter().filter_map(|(_, n)| n.label().map(str::to_string)).collect()
}
/// Clicks the control labeled `label`.
fn click(u: &mut Harness, label: &str) {
let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
@@ -87,10 +121,128 @@ fn click(u: &mut Harness, label: &str) {
#[test]
fn a_prototype_opens_web_links() {
assert_eq!(opens_after_a_delay("https://example.com/a"), ["https://example.com/a"]);
assert_eq!(opens_after_a_delay("mailto:a@b.c"), ["mailto:a@b.c"]);
// A mail link opens the mail app without the notice.
assert_eq!(opened(&presented("mailto:a@b.c", false).1), ["mailto:a@b.c"]);
// The viewer chose to skip the notice: their yes, kept, for the links they click.
assert_eq!(opens_on_a_click("https://example.com/a"), ["https://example.com/a"]);
assert_eq!(opens_on_a_click("mailto:a@b.c"), ["mailto:a@b.c"]);
// With the notice on a mail link waits behind it like a website: nothing reaches the mail
// app, or any opener, unasked (#1096).
let (mut u, seen) = presented("mailto:a@b.c", false);
assert!(opened(&seen).is_empty(), "a mail link fired without a click waits behind the notice");
assert_eq!(notice(&u), "mailto:a@b.c");
click(&mut u, "Open link");
assert_eq!(opened(&seen), ["mailto:a@b.c"]);
assert!(notice(&u).is_null());
}
/// Skipping the notice covers what the viewer does, not what the prototype does by itself: a link
/// on a delay still waits behind the notice, which then offers no "Don't show this again", as
/// that couldn't cover it (#1096).
#[test]
fn a_skipped_notice_still_shows_for_a_link_no_click_fired() {
for url in ["https://example.com/a", "mailto:a@b.c"] {
let (mut u, seen) = presented(url, true);
assert!(opened(&seen).is_empty(), "{url}: a delay opened it with the notice skipped");
assert_eq!(notice(&u), url);
assert!(!labels(&mut u).iter().any(|l| l == "Don't show this again"), "{url}: the checkbox can't skip this notice");
click(&mut u, "Open link");
assert_eq!(opened(&seen), [url]);
}
}
/// A clicked link the notice first shows for offers "Don't show this again" (#1096).
#[test]
fn the_notice_for_a_clicked_link_offers_to_skip_it() {
let (mut u, _) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["https://example.com/a"]);
click_the_frame(&mut u);
assert_eq!(notice(&u), "https://example.com/a");
assert!(labels(&mut u).iter().any(|l| l == "Don't show this again"));
}
/// With the notice skipped one click opens one link, however many its interaction opens: the
/// next waits behind the notice and the rest are dropped (#1096).
#[test]
fn a_click_opens_one_link_with_the_notice_skipped() {
let urls = ["https://example.com/1", "https://example.com/2", "mailto:a@b.c"];
let (mut u, seen) = playing("proto.present", true, json!({"trigger": {"type": "onClick"}}), &urls);
click_the_frame(&mut u);
assert_eq!(opened(&seen), ["https://example.com/1"]);
assert_eq!(notice(&u), "https://example.com/2");
}
/// The pointer passing over a layer is not the viewer asking for a link: with the notice skipped,
/// a mouse enter's link still waits behind it (#1096).
#[test]
fn a_link_the_pointer_fires_by_passing_waits_with_the_notice_skipped() {
let (mut u, seen) = playing("proto.present", true, json!({"trigger": {"type": "mouseEnter"}}), &["https://example.com/hover"]);
u.frame(vec![egui::Event::PointerMoved(egui::pos2(700.0, 290.0))]);
u.frames(3, Vec::new);
assert!(opened(&seen).is_empty(), "passing over the layer opened it");
assert_eq!(notice(&u), "https://example.com/hover");
}
/// A presentation whose prototype queues several mail links on delays opens none of them by
/// itself: the first waits behind the notice and the rest are dropped with it (#1096).
#[test]
fn delayed_opens_do_not_pile_up_unattended() {
let mut s = Session::new();
s.prefs.link_notice_skipped = true;
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"})));
for ms in [100u32, 200, 300] {
let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f})));
ex(
&mut s,
"proto.interaction.add",
json!({"id": layer, "trigger": {"type": "afterDelay", "ms": ms}, "action": {"type": "openUrl", "url": format!("mailto:victim@example.com?subject=spam{ms}")}}),
);
}
let (mut u, seen) = present("proto.present", s, f);
u.frames(40, Vec::new); // no click, no key: the presentation alone
assert!(opened(&seen).is_empty(), "a presentation by itself opens nothing, the notice skipped or not");
assert_eq!(notice(&u), "mailto:victim@example.com?subject=spam100", "the first waits, the rest are dropped");
click(&mut u, "Open link");
assert_eq!(opened(&seen), ["mailto:victim@example.com?subject=spam100"]);
}
/// A mail link the viewer clicks is still leaving the prototype: it waits behind the notice like
/// every other link (#1096). Before, a mail link opened straight away, trigger or no trigger.
#[test]
fn a_clicked_mail_link_waits_behind_the_notice() {
let (mut u, seen) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["mailto:a@b.c?subject=hello"]);
click_the_frame(&mut u);
assert!(opened(&seen).is_empty(), "the notice comes first, whatever fired the link");
assert_eq!(notice(&u), "mailto:a@b.c?subject=hello");
click(&mut u, "Open link");
assert_eq!(opened(&seen), ["mailto:a@b.c?subject=hello"]);
}
/// A mouse-up whose actions run on a delay fires once the click is long over; its link waits
/// behind the notice like a timed one does, the notice skipped or not (#1096).
#[test]
fn a_link_a_click_queued_to_fire_later_waits_behind_the_notice() {
for skip in [false, true] {
let (mut u, seen) = playing("proto.present", skip, json!({"trigger": {"type": "mouseUp"}, "delayMs": 150}), &["https://example.com/late"]);
click_the_frame(&mut u);
u.frames(20, Vec::new); // well past the 150 ms the click queued
assert!(opened(&seen).is_empty(), "skip {skip}: the actions fire after the click is over: not a gesture");
assert_eq!(notice(&u), "https://example.com/late");
}
}
/// A link a key trigger fires waits behind the notice too, and opens at once with it skipped: a
/// key is the viewer's own doing (#1096).
#[test]
fn a_link_fired_by_a_key_waits_behind_the_notice() {
for skip in [false, true] {
let (mut u, seen) = playing("proto.present", skip, json!({"trigger": {"type": "keyPress", "key": "K"}}), &["https://example.com/keyed"]);
u.key(egui::pos2(700.0, 450.0), egui::Key::K);
u.frames(3, Vec::new);
if skip {
assert_eq!(opened(&seen), ["https://example.com/keyed"]);
} else {
assert!(opened(&seen).is_empty(), "a key is not a click");
assert_eq!(notice(&u), "https://example.com/keyed");
}
}
}
#[test]
@@ -192,7 +344,8 @@ fn the_prototype_takes_no_controller_buttons_while_the_notice_shows() {
#[test]
fn dont_show_this_again_is_saved_with_the_preferences() {
let (mut u, seen) = presented("https://example.com/a", false);
let (mut u, seen) = playing("proto.present", false, json!({"trigger": {"type": "onClick"}}), &["https://example.com/a"]);
click_the_frame(&mut u);
click(&mut u, "Don't show this again");
click(&mut u, "Open link");
assert_eq!(opened(&seen), ["https://example.com/a"]);
@@ -216,7 +369,7 @@ fn a_prototype_cannot_open_a_script_or_handler_url() {
"ms-msdt:/id PCWDiagnostic",
"figcraft://join?host=127.0.0.1:47900&file=f&token=t",
] {
assert!(opens_after_a_delay(url).is_empty(), "{url:?} must never reach the opener");
assert!(opens_on_a_click(url).is_empty(), "{url:?} must never reach the opener");
}
}
+3 -2
View File
@@ -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-29 · **Change:** patch (Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); 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,
@@ -94,7 +94,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Hotspot hints, inspect panel in the presenter, comments in the presenter | done | |
| Preview pane following edits live | done | |
| Edit interactions on several layers at once | done | |
| Open link: a notice before a website opens, with a checkbox to skip it on this device | done | `http` and `https` links (a mail link opens at once); Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
| Open link: a notice before a link opens, with a checkbox to skip it on this device | done | every openable link — `http`, `https` and `mailto` — and whatever fired it, a click, a delay, a key (a further link that fires while the notice shows is dropped); skipping it opens at once only a link the viewer's own click, press, drag or key fired, one per frame — a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, without the checkbox; Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) |
| Opening a link in place | missing | |
| Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet |
| Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) |
@@ -105,6 +105,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again" (#1096, `crates/ui-egui/src/present_links_tests.rs`). 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 |