Merge main into #1700

This commit is contained in:
bflatastic
2026-10-31 02:15:51 +07:00
76 changed files with 1758 additions and 110 deletions
Generated
+1
View File
@@ -1820,6 +1820,7 @@ dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
"flate2",
"imbl",
"postcard",
"serde",
+6 -1
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -165,7 +165,10 @@ impl Raw {
pub fn next(&mut self) -> ServerMsg {
loop {
if let Message::Binary(b) = self.0.read().unwrap() {
return message::decode(&b, usize::MAX).unwrap();
// As the app's clients do: a packed welcome is read before anything sees it.
let mut m = message::decode(&b, usize::MAX).unwrap();
message::unpack(&mut m, usize::MAX).unwrap();
return m;
}
}
}
+25 -6
View File
@@ -28,6 +28,9 @@ enum WaitFailure {
Timeout,
/// The UI side dropped its sender before replying (the app is closing).
Disconnected,
/// The UI started the request before the timeout and is still running it: it takes effect
/// when it finishes, though nobody waits for its reply (#1673).
StillRunning,
}
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
@@ -46,10 +49,30 @@ fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGa
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
match gate.cancel_if_open() {
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
// It may have finished just now: its reply is the truth then.
RequestState::Running => rx.try_recv().map_err(|_| WaitFailure::StillRunning),
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
}
}
/// What a request that got no reply is told. Only a request that needs a drawn frame can be held
/// up by the window being out of sight; any other was either never started (the app was busy, so
/// it was dropped and never applied) or is still running and will be applied (#1673).
fn timeout_message(failure: &WaitFailure, needs_frame: bool) -> &'static str {
match failure {
WaitFailure::Disconnected => "the FigCraft window closed before answering",
WaitFailure::StillRunning => {
"the app is still running this request after 60 s: it will take effect when it finishes, but its reply is lost. Check the document before retrying"
}
WaitFailure::Timeout if needs_frame => {
"timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"
}
WaitFailure::Timeout => {
"timed out after 60 s before the app started this request (it was busy, or its window isn't drawing): it was not applied"
}
}
}
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.retain(|request| !request.is_cancelled());
@@ -171,6 +194,7 @@ fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
let needs_frame = matches!(method.as_str(), "ui.screenshot" | "ui.input");
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
@@ -181,12 +205,7 @@ fn serve_with_events(mut out: TcpStream, queue: Queue, ctx: CtxSlot, events: Arc
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(WaitFailure::Timeout) => {
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
Err(WaitFailure::Disconnected) => {
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
}
Err(failure) => json!({"id": id, "ok": false, "error": timeout_message(&failure, needs_frame)}),
}
}
Err(e) => {
+32
View File
@@ -162,6 +162,38 @@ fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
}
/// #1673: a request the UI already started can't be cancelled any more. Its timeout says it is
/// still running and will take effect, not that the window is hidden; one that finished just as
/// the timeout fired gets its real reply.
#[test]
fn a_started_request_times_out_as_still_running() {
let gate = RequestGate::new();
assert!(gate.start(), "an open request starts");
let (tx, rx) = mpsc::channel::<ControlReply>();
let Err(failure) = wait_for_reply(&rx, &gate, Duration::ZERO) else { panic!("no reply was sent") };
assert_eq!(failure, WaitFailure::StillRunning);
assert_eq!(gate.state(), RequestState::Running, "the timeout doesn't cancel a running request");
let message = timeout_message(&failure, false);
assert!(message.contains("will take effect") && !message.contains("window"), "{message}");
tx.send(ControlReply { id: json!(3), result: Ok(json!({"done": true})) }).unwrap();
assert_eq!(wait_for_reply(&rx, &gate, Duration::ZERO).unwrap().id, json!(3), "a reply that made it is the truth");
}
/// #1673: a request the transport gave up on before the UI started it never starts, and its
/// timeout says it wasn't applied; only requests that need a drawn frame blame the window.
#[test]
fn a_request_cancelled_before_it_started_never_runs() {
let gate = RequestGate::new();
let (_tx, rx) = mpsc::channel::<ControlReply>();
let Err(failure) = wait_for_reply(&rx, &gate, Duration::ZERO) else { panic!("no reply was sent") };
assert_eq!(failure, WaitFailure::Timeout);
assert!(!gate.start(), "the UI must not run a cancelled request");
assert_eq!(gate.state(), RequestState::Cancelled);
assert!(timeout_message(&failure, false).contains("not applied"));
assert!(!timeout_message(&failure, false).contains("window on screen"));
assert!(timeout_message(&failure, true).contains("window on screen"), "screenshots and input still need the window");
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
+15 -1
View File
@@ -374,6 +374,7 @@ impl<B: Backend> Hub<B> {
return refuse(&c.out, BARRED);
}
let hosting = reads_hosting(&hello.client_version);
let packed = message::reads_packed(&hello.client_version);
// The backend checked the secret: the sessions don't keep it.
hello.token.clear();
let file = hello.file.clone();
@@ -405,7 +406,7 @@ impl<B: Backend> Hub<B> {
let old = room.ids.insert(id, conn).filter(|old| *old != conn);
self.backend.changed(&file, &mut room.server);
// The newcomer learns how it is shown, then where everyone is.
let mut more = outs;
let mut more = if packed { packed_welcome(outs) } else { outs };
if let Some(me) = room.server.peers().find(|p| p.client_id == id) {
more.push(Outgoing::To(id, ServerMsg::PeerJoined(me.clone())));
}
@@ -747,6 +748,19 @@ impl<B: Backend> Hub<B> {
}
}
/// A joining client's messages with the document in its welcome compressed (its hello announced
/// that it reads that). Should compressing fail, the welcome goes as it was.
fn packed_welcome(mut outs: Vec<Outgoing>) -> Vec<Outgoing> {
for o in &mut outs {
if let Outgoing::To(_, msg) = o
&& let Err(e) = message::pack(msg)
{
log::warn!("event=pack_failed error={e}");
}
}
outs
}
/// Queue `b` for connection `conn`: false when it must be dropped (it can't keep up, or it is
/// gone). When the server's budget for queued bytes is used up, the connection holding the most
/// queued bytes is shed (added to `shed`) to make room, unless that is `conn` itself: whoever
+7 -1
View File
@@ -201,7 +201,13 @@ pub(crate) enum Taken {
/// Take in one message from the host: events for the UI go to `inbox`, and `resume` follows the
/// session. On the welcome, `welcomed` runs before the UI hears of it: what the sync client sends
/// in answer must go out on this connection.
pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
// A compressed document is read here, within the size the welcome could have had unpacked.
match message::unpack(&mut m, cfg.limits.max_server_message) {
Ok(()) => {}
Err(figcraft_collab::Error::TooLarge(_)) => return Taken::End(too_large(&cfg.limits)),
Err(e) => return Taken::End(Outcome::Refused(format!("the host sent a document this version can't read ({e})"))),
}
match m {
ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => {
if let Ok(mut r) = resume.lock() {
+18 -1
View File
@@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
use figcraft_collab::message::{self, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal};
use figcraft_collab::{Client, Limits};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
@@ -575,6 +575,23 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
host.stop();
}
/// The document in a welcome is compressed only for a client whose hello says it reads that; a
/// client from before gets it as it always did (#1665).
#[test]
fn welcomes_are_packed_only_for_clients_that_read_them() {
let (mut host, _hp, invite) = start();
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut old = Raw::connect(addr);
let ClientMsg::Hello(h) = Client::hello(FILE, TOKEN, "Old", "") else { panic!("not a hello") };
assert!(old.send(&ClientMsg::Hello(Hello { client_version: "0.12.1 signals hosting comments".into(), ..h })));
let Some(ServerMsg::Welcome { catch_up: CatchUp::Snapshot(plain), .. }) = old.next() else { panic!("no plain welcome") };
let mut new = Raw::hello(addr, TOKEN);
let Some(mut welcome @ ServerMsg::Welcome { catch_up: CatchUp::Packed(_), .. }) = new.next() else { panic!("no packed welcome") };
message::unpack(&mut welcome, 64 << 20).unwrap();
assert!(matches!(welcome, ServerMsg::Welcome { catch_up: CatchUp::Snapshot(s), .. } if s == plain), "the same document");
host.stop();
}
#[test]
fn signals_reach_only_clients_that_read_them_and_are_rate_limited() {
let (mut host, mut hp, invite) = start();
+1
View File
@@ -17,6 +17,7 @@ chrono = { workspace = true }
imbl = { workspace = true }
base64 = { workspace = true }
postcard = { workspace = true }
flate2 = { workspace = true }
sha2 = { workspace = true }
[dev-dependencies]
+2
View File
@@ -227,6 +227,8 @@ pub enum DocProp {
Timer,
/// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s.
Vote,
/// Ended votes' results kept in the file, oldest first (whole).
VoteHistory,
}
/// A document-level entry, by id.
+5
View File
@@ -560,6 +560,10 @@ impl Client {
peers: Vec<Peer>,
recompute: &dyn Recompute,
) -> Result<Vec<ClientMsg>, Error> {
// The transport unpacks a packed document first (`message::unpack`).
if matches!(catch_up, CatchUp::Packed(_)) {
return Err(Error::Protocol("expected the document unpacked".into()));
}
let last_batch = processed.last_batch;
// An undo or redo lost in flight is made again before it goes out, knowing what others did
// meanwhile: its step conflicts with what they wrote while it was in flight, and with what
@@ -573,6 +577,7 @@ impl Client {
self.presence.clear();
let offline = || "it no longer applied when the connection came back".to_string();
match catch_up {
CatchUp::Packed(_) => {}
CatchUp::Delta(batches) => {
for acc in batches {
if acc.author == self.id {
+1
View File
@@ -269,6 +269,7 @@ doc_scalars! {
Kind => kind,
Timer => timer,
Vote => vote,
VoteHistory => vote_history,
}
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
+5
View File
@@ -65,6 +65,9 @@ pub enum Error {
Encode(String),
#[error("can't decode: {0}")]
Decode(String),
/// A packed document that would grow past this many bytes ([`message::unpack`]).
#[error("the document is over the {0}-byte limit")]
TooLarge(usize),
/// The other side broke the protocol (a message out of order or out of place).
#[error("protocol error: {0}")]
Protocol(String),
@@ -151,6 +154,8 @@ mod merge_tests;
#[cfg(test)]
mod owned_tests;
#[cfg(test)]
mod packed_welcome_tests;
#[cfg(test)]
mod reconnect_tests;
#[cfg(test)]
mod rename_tests;
+59 -1
View File
@@ -1,6 +1,8 @@
//! Messages between clients and the session server, and their two encodings: a compact binary form
//! (a version byte, then postcard) and a JSON form for debugging and agents.
use std::io::{Read, Write};
use figcraft_doc::NodeId;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -27,9 +29,14 @@ pub const HOSTING: &str = "hosting";
/// reads [`Access::Comment`]: older ones get a comment link as a view link.
pub const COMMENTS: &str = "comments";
/// The word a client puts in its [`Hello::client_version`] when it reads [`CatchUp::Packed`]: a
/// server compresses the document in a welcome only for clients that say so (older ones couldn't
/// read it).
pub const PACKED: &str = "packed";
/// The `client_version` this build announces: its version, then what it reads beyond the protocol.
pub fn client_version() -> String {
format!("{} {SIGNALS} {HOSTING} {COMMENTS}", env!("CARGO_PKG_VERSION"))
format!("{} {SIGNALS} {HOSTING} {COMMENTS} {PACKED}", env!("CARGO_PKG_VERSION"))
}
/// Whether `client_version` announces `word`.
@@ -52,6 +59,11 @@ pub fn reads_comments(client_version: &str) -> bool {
announces(client_version, COMMENTS)
}
/// A [`Hello::client_version`] that announces [`PACKED`].
pub fn reads_packed(client_version: &str) -> bool {
announces(client_version, PACKED)
}
/// Join a file's session.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Hello {
@@ -317,6 +329,10 @@ pub enum CatchUp {
Snapshot(Vec<Change>),
/// The batches a reconnecting client missed, in order.
Delta(Vec<Accepted>),
/// A [`Snapshot`](CatchUp::Snapshot) compressed ([`pack`]), for a client whose hello announced
/// [`PACKED`]: a document's changes shrink several times over. The client reads it with
/// [`unpack`] before anything else sees the welcome.
Packed(Vec<u8>),
}
/// Opens a connection that manages a server's files (instead of [`Hello`]): the server's admin
@@ -926,6 +942,11 @@ pub fn decode<T: DeserializeOwned>(bytes: &[u8], max_bytes: usize) -> Result<T,
if version != WIRE_VERSION {
return Err(Error::Decode(format!("wire version {version}, expected {WIRE_VERSION}")));
}
from_postcard(body)
}
/// One postcard value that fills `body` (no trailing bytes).
fn from_postcard<T: DeserializeOwned>(body: &[u8]) -> Result<T, Error> {
let (msg, rest) = postcard::take_from_bytes::<T>(body).map_err(|e| Error::Decode(e.to_string()))?;
if !rest.is_empty() {
return Err(Error::Decode(format!("{} bytes after the message", rest.len())));
@@ -933,6 +954,43 @@ pub fn decode<T: DeserializeOwned>(bytes: &[u8], max_bytes: usize) -> Result<T,
Ok(msg)
}
/// Compress the document in a welcome ([`CatchUp::Packed`]), for a client that announced
/// [`PACKED`]. Anything else, a catch-up by the missed batches too, is left as it is. Deflate at
/// its fastest level: the hub packs on its own thread, and a document's changes still shrink
/// about eightfold.
pub fn pack(msg: &mut ServerMsg) -> Result<(), Error> {
if let ServerMsg::Welcome { catch_up, .. } = msg
&& let CatchUp::Snapshot(changes) = catch_up
{
let raw = postcard::to_allocvec(changes).map_err(|e| Error::Encode(e.to_string()))?;
let mut z = flate2::write::DeflateEncoder::new(Vec::with_capacity(raw.len() / 4), flate2::Compression::fast());
z.write_all(&raw).map_err(|e| Error::Encode(e.to_string()))?;
*catch_up = CatchUp::Packed(z.finish().map_err(|e| Error::Encode(e.to_string()))?);
}
Ok(())
}
/// Undo [`pack`]: a welcome's packed document back as its changes, refused once it would grow
/// past `max_bytes` (untrusted input: the limit an unpacked welcome would have had to fit).
/// Anything else is left as it is.
pub fn unpack(msg: &mut ServerMsg, max_bytes: usize) -> Result<(), Error> {
if let ServerMsg::Welcome { catch_up, .. } = msg
&& let CatchUp::Packed(packed) = catch_up
{
let mut raw = Vec::new();
let limit = u64::try_from(max_bytes).unwrap_or(u64::MAX).saturating_add(1);
flate2::read::DeflateDecoder::new(packed.as_slice())
.take(limit)
.read_to_end(&mut raw)
.map_err(|e| Error::Decode(format!("packed document: {e}")))?;
if raw.len() > max_bytes {
return Err(Error::TooLarge(max_bytes));
}
*catch_up = CatchUp::Snapshot(from_postcard(&raw)?);
}
Ok(())
}
/// The JSON form of `msg` (`{"<type>": {…}}`).
pub fn encode_json<T: Serialize>(msg: &T) -> Result<String, Error> {
serde_json::to_string(msg).map_err(|e| Error::Encode(e.to_string()))
+114
View File
@@ -0,0 +1,114 @@
//! The document in a welcome goes compressed to clients that read that ([`message::PACKED`],
//! #1665): it shrinks several times over, comes back unchanged, and a hostile packed document
//! can't make the client allocate past the size an unpacked welcome could have had.
use std::io::Write;
use figcraft_doc::{Document, NodeKind};
use crate::message::{self, CatchUp, ClientMsg, ServerMsg};
use crate::{Client, Error, Limits, NoRecompute, Outgoing, Replica, Server};
/// A design file of 40 screens with 100 cards each, as a server's first welcome.
fn welcome() -> ServerMsg {
let mut doc = Document::new("Synthetic");
let page = doc.first_page().unwrap();
for f in 0..40 {
let screen =
doc.create(page, NodeKind::Frame, &format!("Screen {f}"), f64::from(f % 8) * 1500.0, f64::from(f / 8) * 1000.0, 1440.0, 900.0).unwrap();
for i in 0..100 {
let (x, y) = (f64::from(i % 10) * 140.0, f64::from(i / 10) * 88.0);
doc.create(screen, NodeKind::Rectangle, &format!("Card {i} of screen {f}"), x, y, 128.0, 80.0).unwrap();
}
}
doc.repair();
let mut s = Server::new(doc, "tok", Limits::default());
let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") };
let (_, out) = s.join(h).unwrap();
out.into_iter().find_map(|o| if let Outgoing::To(_, m @ ServerMsg::Welcome { .. }) = o { Some(m) } else { None }).unwrap()
}
fn with_catch_up(mut msg: ServerMsg, c: CatchUp) -> ServerMsg {
if let ServerMsg::Welcome { catch_up, .. } = &mut msg {
*catch_up = c;
}
msg
}
fn deflated(bytes: &[u8]) -> Vec<u8> {
let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast());
z.write_all(bytes).unwrap();
z.finish().unwrap()
}
#[test]
fn a_packed_welcome_is_much_smaller_and_reads_back_the_same() {
let plain = welcome();
let mut packed = plain.clone();
message::pack(&mut packed).unwrap();
assert!(matches!(packed, ServerMsg::Welcome { catch_up: CatchUp::Packed(_), .. }));
let (before, after) = (message::encode(&plain).unwrap().len(), message::encode(&packed).unwrap().len());
println!("welcome of 4,040 layers: {before} bytes, packed {after} bytes");
assert!(after * 5 < before, "{before} → {after}");
let mut back: ServerMsg = message::decode(&message::encode(&packed).unwrap(), after).unwrap();
message::unpack(&mut back, before).unwrap();
assert_eq!(back, plain);
// The client opens the same document from it.
let ServerMsg::Welcome { catch_up: CatchUp::Snapshot(changes), .. } = &plain else { panic!("not a snapshot") };
assert_eq!(Client::join(back).unwrap().document(), Replica::from_snapshot(changes).unwrap().document());
}
#[test]
fn only_a_welcomes_whole_document_is_packed() {
for msg in [with_catch_up(welcome(), CatchUp::Delta(Vec::new())), ServerMsg::Pong, ServerMsg::PeerLeft { client_id: 3 }] {
let mut m = msg.clone();
message::pack(&mut m).unwrap();
assert_eq!(m, msg, "left as it is");
message::unpack(&mut m, 0).unwrap();
assert_eq!(m, msg, "nothing to unpack");
}
}
#[test]
fn packed_documents_are_untrusted() {
let mut packed = welcome();
message::pack(&mut packed).unwrap();
let ServerMsg::Welcome { catch_up: CatchUp::Snapshot(changes), .. } = welcome() else { panic!("not a snapshot") };
let size = postcard::to_allocvec(&changes).unwrap().len();
// Unpacked, it may be no larger than an unpacked welcome could have been.
assert!(matches!(message::unpack(&mut packed.clone(), size - 1), Err(Error::TooLarge(n)) if n == size - 1));
message::unpack(&mut packed, size).unwrap();
// A bomb stops at the limit instead of filling memory.
let mut bomb = with_catch_up(welcome(), CatchUp::Packed(deflated(&vec![0; 64 << 20])));
assert!(matches!(message::unpack(&mut bomb, 1 << 20), Err(Error::TooLarge(_))));
// Not deflate, not changes, or changes with bytes after them.
let mut good = postcard::to_allocvec(&changes).unwrap();
for bytes in [
vec![0xFF; 64],
deflated(b"not a document"),
deflated(&{
good.push(0);
good
}),
] {
let mut m = with_catch_up(welcome(), CatchUp::Packed(bytes));
assert!(matches!(message::unpack(&mut m, usize::MAX), Err(Error::Decode(_))), "{m:?}");
}
}
#[test]
fn clients_announce_that_they_read_packed_welcomes() {
assert!(message::reads_packed(&message::client_version()));
assert!(!message::reads_packed("0.12.1 signals hosting comments"), "a client from before");
}
/// The sync client reads only what the transport unpacked: a packed document reaching it is a
/// mistake it refuses, on the first welcome and on one after a dropped connection.
#[test]
fn the_sync_client_wants_the_document_unpacked() {
let packed = with_catch_up(welcome(), CatchUp::Packed(Vec::new()));
assert!(Client::join(packed.clone()).is_err());
let mut c = Client::join(welcome()).unwrap();
c.disconnect();
assert!(matches!(c.receive(packed, &NoRecompute), Err(Error::Protocol(_))));
}
+19
View File
@@ -90,6 +90,25 @@ pub struct Vote {
/// Every voter's ballot: the objects they put their votes on (once per vote).
pub type Ballots = BTreeMap<String, Vec<NodeId>>;
/// The most ended votes a board keeps the results of (the oldest goes first).
pub const VOTE_HISTORY_MAX: usize = 20;
/// An ended vote's results, kept in the file: the vote, everyone's ballots and the names the
/// voters went by when it ended (a live session's client ids mean nothing once it's over).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VoteRecord {
pub vote: Vote,
#[serde(default)]
pub ballots: Ballots,
/// Voter key → name, for the keys that aren't names themselves.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub names: BTreeMap<String, String>,
/// When it ended (milliseconds since the Unix epoch).
#[serde(default)]
pub ended: u64,
}
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<Item = NodeId> + 'a {
+4
View File
@@ -245,6 +245,9 @@ pub struct Document {
/// The vote's ballots, by voter.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub votes: Ballots,
/// Ended votes' results kept in the file, oldest first (at most [`VOTE_HISTORY_MAX`]).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub vote_history: Vec<VoteRecord>,
}
impl Default for Document {
@@ -287,6 +290,7 @@ impl Document {
timer: None,
vote: None,
votes: BTreeMap::new(),
vote_history: Vec::new(),
}
}
+11 -1
View File
@@ -160,6 +160,12 @@ impl Session {
Some(Ok(lib)) => Some(lib.manifest.version),
_ => None,
};
// Confined to automation roots, the server isn't asked (#1095). That is no offline
// failure: the last version fetched doesn't stand in for an answer, so `library.updates`
// and `library.list` say the check was refused instead of reporting no updates.
if let Some(why) = crate::file_access::network_refused() {
return Err(EngineError::Other(why));
}
let usage = self.doc().ok().map(|d| {
let id = d.libraries.enabled.iter().find(|l| l.path == path).map(|l| l.id.clone()).unwrap_or_default();
usage(d, &id)
@@ -213,7 +219,11 @@ impl Session {
std::iter::once((l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) })).chain(report)
})
.collect();
if let (false, Some(net)) = (jobs.is_empty(), self.live.link()) {
// Confined to automation roots, nothing reaches a server (#1095).
if jobs.is_empty() || crate::file_access::network_refused().is_some() {
return;
}
if let Some(net) = self.live.link() {
net.library_background(jobs);
}
}
@@ -218,6 +218,18 @@ pub fn current() -> Option<Arc<Roots>> {
ROOTS.with(|r| r.borrow().clone())
}
/// Why this thread's work may not reach the network now, if it may not (#1095): the automation
/// roots keep an agent to the folders it was given, so while they confine it nothing is published,
/// hosted, joined or fetched from a server either. The engine reaches the network only through the
/// live link (`crate::live::Lives`), which asks this first.
pub fn network_refused() -> Option<String> {
current().map(|_| NETWORK_REFUSED.to_string())
}
/// What a confined command that would reach the network says.
pub const NETWORK_REFUSED: &str =
"automation roots are set: commands don't reach the network (publishing, live sessions, team libraries and plugin directories are off)";
/// The path to use for `access` to `path`: resolved when confined, else as given.
pub fn allow(path: impl AsRef<Path>, access: Access) -> Result<PathBuf> {
let path = path.as_ref();
+4
View File
@@ -334,6 +334,10 @@ impl Session {
let sel = ds.selection.clone();
if self.tool.pick_only {
self.tool.picked = Some(c);
} else if ev.cmd && ev.shift {
// Cmd/Ctrl+Shift: make a colour style or variable of it instead (the UI asks which
// and what to call it; nothing is applied).
self.request_ui("dialog.createFromColor", json!({"color": c.to_css_hex()}));
} else if !sel.is_empty() {
self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?;
}
+173 -28
View File
@@ -3,15 +3,16 @@
//! 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::collections::BTreeMap;
use std::sync::Arc;
use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope};
use figcraft_doc::{Ballots, BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTE_HISTORY_MAX, VOTES_MAX, Vote, VoteRecord, 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::params::{id, num, opt_index, req_string, req_targets, string};
use crate::tools::PointerEvent;
use crate::{EngineError, Result, Session, bad, spec};
@@ -47,19 +48,19 @@ pub fn specs() -> Vec<CommandSpec> {
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",
"{action: start|cast|retract|end|clear|delete, 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), index (delete: which kept results, 0 the oldest), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends, and its results are kept in the file (the last 20 votes'); clear takes the results off the board, delete removes kept ones",
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)}",
"{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank, voters: [{voter, name (null: someone who left the live session), votes}]}] (once ended, most votes first, with who voted for each), sessions [{index (in the kept results, 0 the oldest; null for one not kept), onBoard, ended (ms since 1970), votesPerPerson, on, by, results}] (every ended vote's results, newest first)}",
false,
|s, p| {
require(s, "board.voteResults")?;
let who = voter(s, p);
Ok(vote_json(s.doc()?, &who))
vote_json(s, &who)
}
),
]
@@ -176,12 +177,7 @@ fn vote_cmd(s: &mut Session, p: &Value) -> Result<Value> {
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;
}
}
"end" => end_vote(s, CMD)?,
"clear" => {
let doc = s.doc()?;
if doc.vote.is_some() || !doc.votes.is_empty() {
@@ -190,9 +186,50 @@ fn vote_cmd(s: &mut Session, p: &Value) -> Result<Value> {
doc.votes.clear();
}
}
other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))),
"delete" => delete_results(s, p, CMD)?,
other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear|delete)"))),
}
Ok(vote_json(s.doc()?, &who))
vote_json(s, &who)
}
/// `board.vote {action: end}`: everyone's votes show, and the results are kept in the file with
/// the names the voters go by now (the last [`VOTE_HISTORY_MAX`] votes').
fn end_vote(s: &mut Session, cmd: &str) -> Result<()> {
running(cmd, s.doc()?)?;
let names = s.doc()?.votes.keys().filter_map(|k| voter_name(s, k).filter(|n| n != k).map(|n| (k.clone(), n))).collect();
let ended = now_ms();
let doc = s.doc_mut()?;
if let Some(v) = doc.vote.as_mut() {
v.ended = true;
doc.vote_history.push(VoteRecord { vote: v.clone(), ballots: doc.votes.clone(), names, ended });
let extra = doc.vote_history.len().saturating_sub(VOTE_HISTORY_MAX);
doc.vote_history.drain(..extra);
}
Ok(())
}
/// `board.vote {action: delete, index}`: kept results deleted from the file (`index` 0 the
/// oldest, as `board.voteResults` lists them); those on the board now are cleared from it too.
fn delete_results(s: &mut Session, p: &Value, cmd: &str) -> Result<()> {
let doc = s.doc()?;
if doc.vote_history.is_empty() {
return Err(EngineError::Disabled(cmd.into(), "no results are kept".into()));
}
let last = doc.vote_history.len() - 1;
let index = opt_index(cmd, p, "index")?.filter(|i| *i <= last).ok_or_else(|| bad(cmd, format!("`index` is from 0 to {last}")))?;
let on_board = doc.vote_history.get(index).is_some_and(|r| on_board(doc, r));
let doc = s.doc_mut()?;
doc.vote_history.remove(index);
if on_board {
doc.vote = None;
doc.votes.clear();
}
Ok(())
}
/// Whether kept results `r` are the ended vote on the board now.
fn on_board(doc: &Document, r: &VoteRecord) -> bool {
doc.vote.as_ref().is_some_and(|v| v.ended && *v == r.vote) && doc.votes == r.ballots
}
/// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results.
@@ -280,13 +317,119 @@ pub fn object_name(doc: &Document, id: NodeId) -> String {
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 {
/// Who put votes on `id` in `ballots`, and how many each, by voter (as the ballots key them).
pub fn voters_of(doc: &Document, vote: &Vote, ballots: &Ballots, id: NodeId) -> Vec<(String, u32)> {
ballots
.iter()
.filter_map(|(who, ballot)| {
let n = vote.counted(doc, ballot).filter(|v| *v == id).count();
(n > 0).then(|| (who.clone(), u32::try_from(n).unwrap_or(u32::MAX)))
})
.collect()
}
/// One object in an ended vote's results.
#[derive(Clone, Debug, PartialEq)]
pub struct ResultRow {
/// Ties share a rank ("1, 2, 2, 4").
pub rank: usize,
pub id: NodeId,
/// The sticky's text or the layer's name ([`object_name`]).
pub name: String,
pub votes: u32,
/// Who voted for it: their key, the name they go by (`None`: someone who left a live session
/// before the vote ended) and their votes on it.
pub voters: Vec<(String, Option<String>, u32)>,
}
/// An ended vote's results: kept in the file (`index` in [`Document::vote_history`]), on the
/// board now (`on_board`: its badges show), or both.
#[derive(Clone, Debug, PartialEq)]
pub struct VoteSession {
pub index: Option<usize>,
pub on_board: bool,
/// When it ended (ms since 1970; 0 when not known).
pub ended: u64,
pub vote: Vote,
pub rows: Vec<ResultRow>,
}
/// `ballots`' results under `vote`, most votes first; voters named from `names` (as they were
/// when it ended), else as the session knows them now ([`voter_name`]).
fn result_rows(s: &Session, doc: &Document, vote: &Vote, ballots: &Ballots, names: &BTreeMap<String, String>) -> Vec<ResultRow> {
let name = |key: &str| names.get(key).cloned().or_else(|| voter_name(s, key));
ranked(doc, vote, ballots)
.into_iter()
.map(|(rank, id, votes)| {
let voters = voters_of(doc, vote, ballots, id).into_iter().map(|(key, n)| (key.clone(), name(&key), n)).collect();
ResultRow { rank, id, name: object_name(doc, id), votes, voters }
})
.collect()
}
/// Every ended vote's results, newest first: those the file keeps, and the ended vote on the
/// board when the file doesn't keep it (one that ended before results were kept).
pub fn sessions(s: &Session) -> Result<Vec<VoteSession>> {
let doc = s.doc()?;
let mut out: Vec<VoteSession> = doc
.vote_history
.iter()
.enumerate()
.map(|(i, r)| VoteSession {
index: Some(i),
on_board: on_board(doc, r),
ended: r.ended,
vote: r.vote.clone(),
rows: result_rows(s, doc, &r.vote, &r.ballots, &r.names),
})
.collect();
if let Some(vote) = doc.vote.as_ref().filter(|v| v.ended)
&& !out.iter().any(|x| x.on_board)
{
let rows = result_rows(s, doc, vote, &doc.votes, &BTreeMap::new());
out.push(VoteSession { index: None, on_board: true, ended: 0, vote: vote.clone(), rows });
}
out.reverse();
Ok(out)
}
/// The name voter `key` goes by: in a live session a client id's ("peer 3") is the person's
/// name there (yours or another's); `None` for one who isn't in the session any more. Any other
/// key is a name already (yours offline, or a `voter` given to `board.vote`).
pub fn voter_name(s: &Session, key: &str) -> Option<String> {
let Some(client) = key.strip_prefix("peer ").and_then(|n| n.parse::<u32>().ok()) else { return Some(key.to_string()) };
let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).map(|ds| s.live_of(ds.uid))?;
match &live.info {
Some(info) if info.me == client => Some(info.name.clone()),
_ => live.peers.get(&client).map(|p| p.name.clone()),
}
}
fn scope_json(scope: &VoteScope) -> Value {
match scope {
VoteScope::All => json!("all"),
VoteScope::Stickies => json!("stickies"),
VoteScope::Only { ids } => json!({"selection": ids}),
};
}
}
fn rows_json(rows: &[ResultRow]) -> Value {
let voters = |r: &ResultRow| r.voters.iter().map(|(key, name, n)| json!({"voter": key, "name": name, "votes": n})).collect::<Vec<_>>();
json!(rows.iter().map(|r| json!({"id": r.id, "name": r.name, "votes": r.votes, "rank": r.rank, "voters": voters(r)})).collect::<Vec<_>>())
}
fn vote_json(s: &Session, who: &str) -> Result<Value> {
let sessions = sessions(s)?;
let kept: Vec<Value> = sessions
.iter()
.map(|x| {
json!({"index": x.index, "onBoard": x.on_board, "ended": x.ended, "votesPerPerson": x.vote.per_person,
"on": scope_json(&x.vote.scope), "by": x.vote.by, "results": rows_json(&x.rows)})
})
.collect();
let doc = s.doc()?;
let Some(vote) = &doc.vote else { return Ok(json!({"state": "off", "sessions": kept})) };
let on = scope_json(&vote.scope);
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();
@@ -298,20 +441,21 @@ fn vote_json(doc: &Document, who: &str) -> Value {
"left": vote.per_person.saturating_sub(used),
"mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::<Vec<_>>(),
"voters": voters,
"sessions": kept,
});
if vote.ended {
let results: Vec<Value> =
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);
// Who voted for what shows once the vote ends; until then each person sees only their own.
if let Some(on_board) = sessions.iter().find(|x| x.on_board).filter(|_| vote.ended) {
out["results"] = rows_json(&on_board.rows);
}
out
Ok(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)> {
/// The objects with votes in `ballots`, most first, each with its rank (ties share one: "1, 2,
/// 2, 4").
pub fn ranked(doc: &Document, vote: &Vote, ballots: &Ballots) -> Vec<(usize, NodeId, u32)> {
let mut last = None;
let mut rank = 0;
vote.tally(doc, &doc.votes)
vote.tally(doc, ballots)
.into_iter()
.enumerate()
.map(|(i, (id, n))| {
@@ -361,13 +505,14 @@ impl Session {
}
}
/// 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`).
/// Undo and redo bring back a document as it was, but the timer, the vote and the kept results
/// aren't undoable: keep them as they are now (`from`).
pub(crate) fn keep(from: &Document, to: &mut Arc<Document>) {
if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes {
if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes || from.vote_history != to.vote_history {
let d = Arc::make_mut(to);
d.timer = from.timer;
d.vote = from.vote.clone();
d.votes = from.votes.clone();
d.vote_history = from.vote_history.clone();
}
}
+5
View File
@@ -594,6 +594,11 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
}
None => doc.libraries.enabled.iter().filter(|l| is_server_link(&l.path)).map(|l| l.path.clone()).collect(),
};
// Confined to automation roots, nothing is checked (#1095): say so, and don't remember the
// libraries as yours either.
if let (false, Some(why)) = (links.is_empty(), crate::file_access::network_refused()) {
return Err(EngineError::Disabled(cmd.to_string(), why));
}
for l in &links {
s.allow_library(l);
}
@@ -0,0 +1,66 @@
//! The eyedropper with Cmd/Ctrl+Shift held makes a colour style or variable of what it picks (as
//! the reference app's eyedropper does): it asks the UI which (`dialog.createFromColor`) and leaves the
//! selection's fills alone; a plain pick still fills the selection.
use figcraft_doc::NodeId;
use serde_json::json;
use crate::Session;
use crate::tools::{PointerEvent, PointerKind, ToolId};
/// A white page with a red square at (0, 0) and a green one selected at (200, 0).
fn scene() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap();
let rect = |s: &mut Session, x: f64, fill: &str| {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap();
let id = crate::params::id_of(&r["id"]).unwrap();
s.execute("node.set", &json!({"id": id, "props": {"fills": [fill]}})).unwrap();
id
};
rect(&mut s, 0.0, "#FF0000");
let green = rect(&mut s, 200.0, "#00FF00");
s.execute("select.set", &json!({"ids": [green]})).unwrap();
s.ui_requests.clear();
(s, green)
}
fn pick(s: &mut Session, cmd: bool, shift: bool) {
s.execute("edit.pickColor", &json!({})).unwrap();
s.pointer(&PointerEvent { cmd, shift, ..PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0) }).unwrap();
}
fn fill(s: &Session, id: NodeId) -> Option<String> {
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex())
}
#[test]
fn cmd_shift_asks_to_make_a_style_or_variable_of_the_pick() {
let (mut s, green) = scene();
let undo = s.undo_depth().0;
pick(&mut s, true, true);
let asks: Vec<_> = s.ui_requests.iter().filter(|r| r.kind == "dialog.createFromColor").collect();
assert_eq!(asks.len(), 1, "{:?}", s.ui_requests);
assert_eq!(asks[0].data["color"], "#FF0000", "the colour under the pointer");
assert_eq!(fill(&s, green), Some("00FF00".into()), "the selection keeps its fill");
assert_eq!(s.undo_depth().0, undo, "nothing to undo");
assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper");
assert_eq!(s.recent_colors.first().map(|c| c.to_hex()), Some("FF0000".into()), "a recent colour as any pick");
}
#[test]
fn other_picks_fill_the_selection_as_before() {
for (cmd, shift) in [(false, false), (true, false), (false, true)] {
let (mut s, green) = scene();
pick(&mut s, cmd, shift);
assert!(!s.ui_requests.iter().any(|r| r.kind == "dialog.createFromColor"), "cmd {cmd} shift {shift}");
assert_eq!(fill(&s, green), Some("FF0000".into()), "cmd {cmd} shift {shift}");
}
// The colour picker's eyedropper only samples, whatever is held.
let (mut s, green) = scene();
s.execute("edit.pickColor", &json!({"apply": false})).unwrap();
s.pointer(&PointerEvent { cmd: true, shift: true, ..PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0) }).unwrap();
assert!(s.ui_requests.is_empty());
assert_eq!(s.tool.picked.map(|c| c.to_hex()), Some("FF0000".into()));
assert_eq!(fill(&s, green), Some("00FF00".into()));
}
+29 -6
View File
@@ -132,12 +132,14 @@ fn votes_are_capped_hidden_until_the_end_and_ranked() {
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");
// Once it ends, everyone sees who voted for what.
let voter = |name: &str, votes: u32| json!({"voter": name, "name": name, "votes": votes});
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},
{"id": a.0, "name": "Ship it", "votes": 3, "rank": 1, "voters": [voter("Ada", 2), voter("Cy", 1)]},
{"id": b.0, "name": "Wait", "votes": 2, "rank": 2, "voters": [voter("Bo", 1), voter("Cy", 1)]},
{"id": c.0, "name": "Rethink", "votes": 1, "rank": 3, "voters": [voter("Bo", 1)]},
])
);
// A deleted object's votes no longer count.
@@ -238,10 +240,26 @@ fn everyone_in_a_live_session_shares_the_timer_and_the_vote() {
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"}));
// Who voted shows by the names people go by in the session, kept with the results when it
// ends (this room delivers no join messages, so peer 2 is added as one would add them).
let me = room.s(0).live_mut().and_then(|l| l.info.as_ref()).map(|i| i.name.clone()).expect("in the session");
let peer2 = figcraft_collab::Peer { client_id: 2, name: "Grace".into(), color: "#00AA88".into() };
room.s(0).live_mut().expect("in the session").peers.insert(2, peer2);
ex(room.s(0), "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}]));
room.check();
let grace = json!({"voter": "peer 2", "name": "Grace", "votes": 1});
let want = json!([
{"id": a.0, "name": "A", "votes": 2, "rank": 1, "voters": [{"voter": "peer 1", "name": me, "votes": 1}, grace]},
{"id": b.0, "name": "B", "votes": 1, "rank": 2, "voters": [grace]},
]);
for i in 0..2 {
assert_eq!(ex(room.s(i), "board.voteResults", json!({}))["results"], want, "everyone sees them ({i})");
}
// Someone who has left keeps the name the results were kept with; their votes still count.
room.s(0).live_mut().expect("in the session").peers.remove(&2);
assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["results"], want);
assert_eq!(room.server.document().vote_history.len(), 1, "the results are kept for everyone");
// Undo in a session takes back your own edits only, not the vote.
room.s(0).undo().unwrap();
room.settle();
@@ -292,6 +310,11 @@ fn facilitation_commands_survive_junk() {
json!({"action": "end"}),
json!({"action": "clear", "ids": 7}),
json!({"action": "resume", "minutes": [1]}),
json!({"action": "delete"}),
json!({"action": "delete", "index": -1}),
json!({"action": "delete", "index": 1e308}),
json!({"action": "delete", "index": "0"}),
json!({"action": "delete", "index": 0}),
];
for cmd in ["board.timer", "board.timerState", "board.vote", "board.voteResults"] {
for j in &junk {
+4
View File
@@ -309,6 +309,8 @@ mod export_depth_tests;
#[cfg(test)]
mod extended_chain_tests;
#[cfg(test)]
mod eyedropper_create_tests;
#[cfg(test)]
mod facilitate_tests;
#[cfg(test)]
mod fig_export_assets_tests;
@@ -607,6 +609,8 @@ mod version_tests;
#[cfg(test)]
mod view_behaviour_tests;
#[cfg(test)]
mod vote_history_tests;
#[cfg(test)]
mod vue_codegen_tests;
#[cfg(test)]
mod widget_hover_tests;
+24
View File
@@ -115,3 +115,27 @@ fn checking_one_team_library_by_name() {
assert_eq!(sent(&wire), vec![LINK.to_string()]);
let _ = std::fs::remove_file(path);
}
/// Confined to automation roots (#1095), checking says it was refused instead of counting
/// libraries it never asked about, and doesn't remember them as yours: unconfined later, they are
/// still not asked by themselves.
#[test]
fn a_confined_check_is_refused_before_the_libraries_are_remembered() {
let (mut s, wire, path) = opened(Prefs::default(), "confined");
let dir = std::env::temp_dir().join(format!("figcraft-beacon-roots-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap());
crate::file_access::confined(Some(&roots), || {
for p in [json!({}), json!({"library": "Kit"})] {
let e = s.execute("library.check", &p).unwrap_err();
assert!(matches!(e, crate::EngineError::Disabled(..)), "{e}");
assert!(e.to_string().contains(crate::file_access::NETWORK_REFUSED), "{e}");
}
});
assert!(sent(&wire).is_empty(), "nothing was asked: {:?}", sent(&wire));
assert!(!s.library_allowed(LINK) && !s.live.unsaved, "not remembered");
s.refresh_server_libraries();
assert!(sent(&wire).is_empty());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_file(path);
}
+3
View File
@@ -128,6 +128,9 @@ impl Session {
/// answered when it was an earlier one (what it carried then).
pub(crate) fn net(&mut self, req: NetRequest) -> Result<(NetReply, Option<NetRequest>)> {
let Some(link) = self.live.link() else { return Err(EngineError::Other(req.unavailable().into())) };
if let Some(why) = crate::file_access::network_refused() {
return Err(EngineError::Other(why));
}
if let Some(run) = self.background.run.as_mut() {
let earlier = run.answers.iter().zip(run.used.iter_mut()).find(|((sent, _), used)| !**used && sent.stands_for(&req));
if let Some(((sent, reply), used)) = earlier {
@@ -270,3 +270,65 @@ fn a_link_without_background_requests_is_waited_on() {
let mut bare = Session::new();
assert!(bare.start_background("plugin.directory.list", json!({"server": SERVER})).unwrap_err().to_string().contains("desktop app"));
}
/// Confined to automation roots, commands reach no server (#1095): browsing a plugin directory,
/// hosting, joining and publishing are refused before the link is asked. Unconfined, the same
/// request reaches the server as before.
#[test]
fn commands_confined_to_automation_roots_reach_no_server() {
let dir = std::env::temp_dir().join(format!("figcraft-roots-network-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap());
let (mut s, wire) = session(false);
let refused = |r: Result<serde_json::Value, EngineError>| {
let e = r.unwrap_err().to_string();
assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{e}");
};
crate::file_access::confined(Some(&roots), || {
refused(s.execute("plugin.directory.list", &json!({"server": SERVER})));
refused(s.execute("collab.host", &json!({})));
refused(s.execute("collab.join", &json!({"link": LINK})));
refused(s.execute("collab.publish", &json!({"server": SERVER})));
});
assert_eq!(wire.lock().unwrap().waited, 0, "the server was never asked");
assert!(s.live.iter().next().is_none(), "no session was started");
let _ = std::fs::remove_dir_all(&dir);
let r = s.execute("plugin.directory.list", &json!({"server": SERVER})).unwrap();
assert_eq!(r["plugins"][0]["id"], ID);
assert_eq!(wire.lock().unwrap().waited, 1);
let e = s.execute("collab.host", &json!({})).unwrap_err().to_string();
assert!(!e.contains(crate::file_access::NETWORK_REFUSED), "unconfined, the link is asked: {e}");
}
/// A team library fetched before stays cached, but confined to automation roots (#1095) checking
/// it for updates is refused, and `library.updates` and `library.list` say so instead of
/// answering from the cache as if the server had nothing newer.
#[test]
fn a_confined_update_check_reports_the_refusal_not_the_cached_library() {
let (mut s, wire) = session(false);
wire.lock().unwrap().library = Some(published());
s.execute("library.add", &json!({"path": LINK})).unwrap();
assert!(matches!(s.libraries.cached(LINK), Some(Ok(_))));
let waited = wire.lock().unwrap().waited;
let dir = std::env::temp_dir().join(format!("figcraft-roots-updates-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let roots = Arc::new(crate::file_access::Roots::new(&[], &[dir.to_string_lossy().into_owned()]).unwrap());
crate::file_access::confined(Some(&roots), || {
let u = s.execute("library.updates", &json!({})).unwrap();
assert_eq!(u["count"], 0);
let e = u["errors"][0]["error"].as_str().unwrap();
assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{u}");
let l = s.execute("library.list", &json!({})).unwrap();
let e = l["libraries"][0]["error"].as_str().unwrap();
assert!(e.contains(crate::file_access::NETWORK_REFUSED), "{l}");
});
assert_eq!(wire.lock().unwrap().waited, waited, "the server was never asked");
assert!(matches!(s.libraries.cached(LINK), Some(Ok(_))), "the cached library is kept");
let _ = std::fs::remove_dir_all(&dir);
// Unconfined, the same check reaches the server and finds nothing new.
let u = s.execute("library.updates", &json!({})).unwrap();
assert_eq!((u["count"].clone(), u["errors"].as_array().map(Vec::len)), (json!(0), Some(0)), "{u}");
assert_eq!(wire.lock().unwrap().waited, waited + 1);
}
+3
View File
@@ -274,6 +274,9 @@ impl Lives {
/// session's id, which comes back with what it returned.
fn start<T>(&mut self, cmd: &str, start: impl FnOnce(&mut dyn LiveLink, SessionId) -> Result<T>) -> Result<(SessionId, T)> {
let link = self.link.as_deref_mut().ok_or_else(|| unavailable(cmd))?;
if let Some(why) = crate::file_access::network_refused() {
return Err(EngineError::Disabled(cmd.to_string(), why));
}
let id = self.last.saturating_add(1);
let out = start(link, id)?;
self.last = id;
+1 -1
View File
@@ -99,7 +99,7 @@ impl Session {
pub(crate) fn team_refresh(&mut self) -> Result<()> {
let server = plain_server(&self.prefs.publish_server);
let public = option_env!("FIGCRAFT_DEFAULT_SERVER").is_none() && same_server(&server, DEFAULT_SERVER);
if server.is_empty() || public || self.live.link().is_none() {
if server.is_empty() || public || self.live.link().is_none() || crate::file_access::network_refused().is_some() {
self.live.team = None;
return Ok(());
}
+152
View File
@@ -0,0 +1,152 @@
//! Earlier votes' results kept in the board file (the reference app keeps each voting session's
//! results until they're deleted): recorded when a vote ends, at most [`VOTE_HISTORY_MAX`], listed
//! newest first, deleted one at a time, saved, carried over undo, and opened from older files.
use figcraft_doc::{NodeId, VOTE_HISTORY_MAX, Vote, VoteRecord, VoteScope};
use serde_json::{Value, json};
use crate::board_tests::{board, id};
use crate::{EngineError, Session};
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")
}
/// A vote of `votes` (voter, object) pairs, ended.
fn vote(s: &mut Session, votes: &[(&str, NodeId)]) {
ex(s, "board.vote", json!({"action": "start", "votes": 3}));
for (who, on) in votes {
ex(s, "board.vote", json!({"action": "cast", "id": on, "voter": who}));
}
ex(s, "board.vote", json!({"action": "end"}));
}
/// A listed session: its index, whether it's on the board, and its objects' votes (id, votes).
type Listed = (Value, bool, Vec<(u64, u64)>);
fn sessions(s: &mut Session) -> Vec<Listed> {
let r = ex(s, "board.voteResults", json!({}));
r["sessions"]
.as_array()
.unwrap()
.iter()
.map(|x| {
let rows = x["results"].as_array().unwrap().iter().map(|r| (r["id"].as_u64().unwrap(), r["votes"].as_u64().unwrap())).collect();
(x["index"].clone(), x["onBoard"].as_bool().unwrap(), rows)
})
.collect()
}
#[test]
fn ended_votes_are_kept_newest_first_and_the_last_twenty() {
let mut s = board();
let (a, b) = (sticky(&mut s, 0.0, "A"), sticky(&mut s, 300.0, "B"));
vote(&mut s, &[("Ada", a), ("Bo", a)]);
vote(&mut s, &[("Ada", b)]);
assert_eq!(
sessions(&mut s),
vec![(json!(1), true, vec![(b.0, 1)]), (json!(0), false, vec![(a.0, 2)])],
"the vote on the board first, then the one before"
);
let kept = &s.doc().unwrap().vote_history;
assert!(kept.iter().all(|r| r.vote.ended && r.ended > 0), "ended, with when");
// Clearing takes the results off the board; starting a vote leaves the kept ones too.
ex(&mut s, "board.vote", json!({"action": "clear"}));
assert_eq!(sessions(&mut s).iter().filter(|x| x.1).count(), 0, "none on the board");
assert_eq!(sessions(&mut s).len(), 2, "still kept");
ex(&mut s, "board.vote", json!({"action": "start"}));
assert_eq!(ex(&mut s, "board.voteResults", json!({}))["sessions"].as_array().map(Vec::len), Some(2), "kept while a vote runs");
ex(&mut s, "board.vote", json!({"action": "end"}));
for _ in 0..VOTE_HISTORY_MAX + 2 {
vote(&mut s, &[("Cy", a)]);
}
assert_eq!(s.doc().unwrap().vote_history.len(), VOTE_HISTORY_MAX, "the oldest go");
assert!(s.doc().unwrap().vote_history.iter().all(|r| r.ballots.contains_key("Cy")), "the first two are gone");
}
#[test]
fn deleting_kept_results() {
let mut s = board();
let (a, b) = (sticky(&mut s, 0.0, "A"), sticky(&mut s, 300.0, "B"));
assert!(matches!(s.execute("board.vote", &json!({"action": "delete", "index": 0})), Err(EngineError::Disabled(..))), "none kept");
vote(&mut s, &[("Ada", a)]);
vote(&mut s, &[("Bo", b)]);
assert!(s.execute("board.vote", &json!({"action": "delete", "index": 2})).is_err(), "no third");
assert!(s.execute("board.vote", &json!({"action": "delete"})).is_err(), "which one");
assert!(s.execute("board.vote", &json!({"action": "undelete", "index": 0})).is_err(), "no such action");
// An earlier one: the vote on the board stays.
ex(&mut s, "board.vote", json!({"action": "delete", "index": 0}));
assert_eq!(sessions(&mut s), vec![(json!(0), true, vec![(b.0, 1)])]);
assert!(s.doc().unwrap().vote.is_some());
// The one on the board: it leaves the board too.
ex(&mut s, "board.vote", json!({"action": "delete", "index": 0}));
let doc = s.doc().unwrap();
assert!(doc.vote_history.is_empty() && doc.vote.is_none() && doc.votes.is_empty());
assert_eq!(ex(&mut s, "board.voteResults", json!({})), json!({"state": "off", "sessions": []}));
}
#[test]
fn kept_results_save_and_older_files_open() {
let mut s = board();
let a = sticky(&mut s, 0.0, "A");
vote(&mut s, &[("Ada", 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.vote_history, doc.vote_history);
// A file from before results were kept: none listed but the ended vote on its board, which
// shows and clears as before (it can't be deleted: it isn't kept).
let mut v = figcraft_format::to_json(&doc).unwrap();
assert!(v.as_object_mut().unwrap().remove("vote_history").is_some(), "saved as vote_history");
let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap();
assert!(old.vote_history.is_empty() && old.vote.is_some());
let mut s = board();
s.ds_mut().unwrap().doc = std::sync::Arc::new(old);
assert_eq!(sessions(&mut s), vec![(Value::Null, true, vec![(a.0, 1)])]);
ex(&mut s, "board.vote", json!({"action": "clear"}));
assert!(sessions(&mut s).is_empty());
// Design files don't write it.
assert!(figcraft_format::to_json(&figcraft_doc::Document::new("D")).unwrap().get("vote_history").is_none());
}
#[test]
fn undo_leaves_kept_results_alone() {
let mut s = board();
let a = sticky(&mut s, 0.0, "A");
vote(&mut s, &[("Ada", a)]);
let b = sticky(&mut s, 300.0, "B");
vote(&mut s, &[("Bo", b)]);
s.undo().unwrap();
assert!(s.doc().unwrap().node(b).is_none(), "the sticky is undone");
assert_eq!(s.doc().unwrap().vote_history.len(), 2, "the results ended since stay");
ex(&mut s, "board.vote", json!({"action": "delete", "index": 0}));
s.redo().unwrap();
assert_eq!(s.doc().unwrap().vote_history.len(), 1, "redo doesn't bring deleted results back");
}
#[test]
fn voters_go_by_the_names_kept_with_the_results() {
let mut s = board();
let a = sticky(&mut s, 0.0, "A");
let record = |names: &[(&str, &str)]| VoteRecord {
vote: Vote { per_person: 3, scope: VoteScope::All, ended: true, by: String::new() },
ballots: [("peer 7".to_string(), vec![a]), ("peer 9".to_string(), vec![a])].into(),
names: names.iter().map(|(k, v)| ((*k).to_string(), (*v).to_string())).collect(),
ended: 1,
};
s.ds_mut().unwrap().doc = {
let mut d = s.doc().unwrap().clone();
d.vote_history = vec![record(&[("peer 7", "Grace")])];
std::sync::Arc::new(d)
};
let r = ex(&mut s, "board.voteResults", json!({}));
assert_eq!(
r["sessions"][0]["results"][0]["voters"],
json!([{"voter": "peer 7", "name": "Grace", "votes": 1}, {"voter": "peer 9", "name": null, "votes": 1}]),
"a name kept, and one nobody knew"
);
}
+9
View File
@@ -23,6 +23,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: طلبات السحب: {prs}، الإيداعات: {commits}، +{added} / −{deleted} سطر (Δ {delta})، +{bin_added} / −{bin_deleted} أصل ثنائي، {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "مجموعة من هذا الملف بأوضاعها ومتغيراتها: تجاوز قيمها لكل وضع وأضف متغيرات خاصة بها"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "يوجد ملف .figcraft بهذا الاسم. اختر اسمه الكامل لتأكيد الاستبدال."
"A later vote" = "تصويت أحدث"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "تُحاذى الطبقة المنفردة مع إطارها: حدد عدة طبقات أو طبقة داخل إطار"
"A responsive product page: hero, features, figures, a quote and a footer" = "صفحة منتج متجاوبة: قسم رئيسي ومزايا وأرقام واقتباس وتذييل"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "تستمر حركة النابض حتى يستقر؛ وتحدد كتلته وصلابته وتخميده مدتها"
@@ -115,6 +116,7 @@ status = "draft — needs native review"
"Alphabetical" = "أبجدي"
"Alt+drag" = "Alt+سحب"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "شاشة تحليلات شبكية: أرقام ومخططات مرسومة بمتجهات وجدول"
"An earlier vote" = "تصويت أقدم"
"An invite link that opens FigCraft and joins" = "رابط دعوة يفتح FigCraft وينضم إلى الجلسة"
"Anchor" = "مرساة"
"Android" = "Android"
@@ -241,6 +243,7 @@ status = "draft — needs native review"
"Convert to frame" = "تحويل إلى إطار"
"Convert to section" = "تحويل إلى قسم"
"CSS custom properties…" = "خصائص CSS المخصصة…"
"Create from color" = "إنشاء من اللون"
"Dart constants for Flutter…" = "ثوابت Dart لـ Flutter…"
"Can edit" = "يمكنه التحرير"
"Can't read this library: {e}" = "تعذرت قراءة هذه المكتبة: {e}"
@@ -407,6 +410,7 @@ status = "draft — needs native review"
"Delete page" = "حذف الصفحة"
"Delete preset" = "حذف الإعداد المسبق"
"Delete property" = "حذف الخاصية"
"Delete results" = "حذف النتائج"
"Delete the style (layers keep their values)" = "حذف النمط (تحتفظ الطبقات بقيمها)"
"Delete thread" = "حذف سلسلة التعليقات"
"Delete variable" = "حذف المتغير"
@@ -811,8 +815,10 @@ status = "draft — needs native review"
"Main menu" = "القائمة الرئيسية"
"Make a component" = "إنشاء مكوّن"
"Make slot" = "إنشاء خانة محتوى"
"New collection" = "مجموعة جديدة"
"Pasted design layers" = "طبقات تصميم ملصقة"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "لُصقت طبقات التصميم؛ لم تحمل الحافظة صور: {layers}"
"Remove these results from the file" = "احذف هذه النتائج من الملف"
"Scroll wheel zoom" = "التكبير بعجلة التمرير"
"Slot name" = "اسم خانة المحتوى"
"Mar" = "مارس"
@@ -951,6 +957,8 @@ status = "draft — needs native review"
"Not read yet" = "لم تُقرأ بعد"
"Check for updates" = "التحقق من التحديثات"
"Not checked yet" = "لم يُتحقق منها بعد"
"Someone who left" = "شخص غادر"
"Take the results off the board; they stay in the file" = "أزل النتائج من اللوحة؛ تبقى في الملف"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "يستخدم هذا الملف مكتبات فريق على خوادم لم تستخدمها هنا. لا يُتصل بها حتى تتحقق منها."
"Not shared" = "غير مشارك"
"Note {n}" = "الملاحظة {n}"
@@ -1822,6 +1830,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · على خادم"
"{name} · published version {v}" = "{name} · الإصدار المنشور {v}"
"{name} · version {v} on the server" = "{name} · الإصدار {v} على الخادم"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}، في جلسة مباشرة"
"{name}, online, open only to you" = "{name}، على الإنترنت ومتاح لك وحدك"
"{name}, {kind}, {w} by {h}, selected" = "{name}، {kind}، {w} × {h}، محدد"
+9
View File
@@ -18,6 +18,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs}টি PR, {commits}টি কমিট, +{added} / −{deleted} লাইন (Δ {delta}), +{bin_added} / −{bin_deleted} বাইনারি অ্যাসেট, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "এই মোড এবং ভেরিয়েবল সহ এই ফাইলের একটি সংগ্রহ: প্রতি মোডে তাদের মান ওভাররাইড করুন এবং এর নিজস্ব ভেরিয়েবল যোগ করুন"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "এই নামের একটি .figcraft ফাইল ইতিমধ্যে বিদ্যমান। প্রতিস্থাপন নিশ্চিত করতে এর সম্পূর্ণ নাম বেছে নিন।"
"A later vote" = "পরের ভোট"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "একটি লেয়ার একা তার ফ্রেমের সাথে সারিবদ্ধ হয়: আরও লেয়ার নির্বাচন করুন, বা ফ্রেমে থাকা একটি লেয়ার নির্বাচন করুন"
"A responsive product page: hero, features, figures, a quote and a footer" = "একটি রেসপনসিভ প্রোডাক্ট পেজ: হিরো, ফিচার, ফিগার, একটি উদ্ধৃতি এবং একটি ফুটার"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "একটি স্প্রিং স্থির হওয়া পর্যন্ত চলে: এর ভর, দৃঢ়তা (stiffness) এবং ড্যাম্পিং নির্ধারণ করে কতক্ষণ চলবে"
@@ -110,6 +111,7 @@ status = "draft — needs native review"
"Alphabetical" = "বর্ণানুক্রমিক"
"Alt+drag" = "Alt+ড্র্যাগ"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "গ্রিডের উপর একটি অ্যানালিটিক্স স্ক্রিন: ফিগার, ভেক্টর থেকে আঁকা চার্ট এবং একটি টেবিল"
"An earlier vote" = "আগের ভোট"
"An invite link that opens FigCraft and joins" = "একটি আমন্ত্রণ লিংক যা FigCraft খুলে যুক্ত হয়"
"Anchor" = "অ্যাঙ্কর"
"Android" = "অ্যান্ড্রয়েড"
@@ -351,6 +353,7 @@ status = "draft — needs native review"
"Create component" = "কম্পোনেন্ট তৈরি করুন"
"Create component property" = "কম্পোনেন্ট প্রপার্টি তৈরি করুন"
"Create components, styles or variables to publish them as a library." = "লাইব্রেরি হিসেবে প্রকাশ করতে কম্পোনেন্ট, স্টাইল বা ভেরিয়েবল তৈরি করুন।"
"Create from color" = "রং থেকে তৈরি করুন"
"Create property…" = "প্রপার্টি তৈরি করুন..."
"Create style" = "স্টাইল তৈরি করুন"
"Create style from the selected text" = "নির্বাচিত টেক্সট থেকে স্টাইল তৈরি করুন"
@@ -387,6 +390,7 @@ status = "draft — needs native review"
"Delete page" = "পেজ ডিলিট করুন"
"Delete preset" = "প্রিসেট ডিলিট করুন"
"Delete property" = "প্রপার্টি ডিলিট করুন"
"Delete results" = "ফলাফল মুছুন"
"Delete the style (layers keep their values)" = "স্টাইল ডিলিট করুন (লেয়ারগুলো তাদের মান ধরে রাখবে)"
"Delete thread" = "থ্রেড ডিলিট করুন"
"Delete variable" = "ভেরিয়েবল ডিলিট করুন"
@@ -779,8 +783,10 @@ status = "draft — needs native review"
"Main menu" = "প্রধান মেনু"
"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন"
"Make slot" = "স্লট তৈরি করুন"
"New collection" = "নতুন সংগ্রহ"
"Pasted design layers" = "পেস্ট করা ডিজাইন লেয়ার"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "ডিজাইন লেয়ার পেস্ট হয়েছে; ক্লিপবোর্ডে এগুলোর ছবি ছিল না: {layers}"
"Remove these results from the file" = "এই ফলাফল ফাইল থেকে মুছুন"
"Scroll wheel zoom" = "স্ক্রল হুইল দিয়ে জুম"
"Slot name" = "স্লটের নাম"
"Mar" = "মার্চ"
@@ -919,6 +925,8 @@ status = "draft — needs native review"
"Not read yet" = "এখনো পড়া হয়নি"
"Check for updates" = "আপডেট দেখুন"
"Not checked yet" = "এখনো যাচাই করা হয়নি"
"Someone who left" = "কেউ একজন যিনি চলে গেছেন"
"Take the results off the board; they stay in the file" = "ফলাফল বোর্ড থেকে সরান; সেগুলো ফাইলে থাকবে"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "এই ফাইলটি এমন সার্ভারের টিম লাইব্রেরি ব্যবহার করে যা আপনি এখানে ব্যবহার করেননি। আপনি যাচাই না করা পর্যন্ত সেগুলোর সাথে যোগাযোগ করা হয় না।"
"Not shared" = "শেয়ার করা হয়নি"
"Note {n}" = "নোট {n}"
@@ -1763,6 +1771,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · একটি সার্ভারে"
"{name} · published version {v}" = "{name} · প্রকাশিত সংস্করণ {v}"
"{name} · version {v} on the server" = "{name} · সার্ভারে সংস্করণ {v}"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, লাইভ সেশনে"
"{name}, online, open only to you" = "{name}, অনলাইনে, শুধুমাত্র আপনার জন্য খোলা"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, নির্বাচিত"
+2
View File
@@ -243,6 +243,7 @@ status = "draft — needs native review"
"Convert to frame" = "Pretvori u okvir"
"Convert to section" = "Pretvori u sekciju"
"CSS custom properties…" = "Prilagođena CSS svojstva…"
"Create from color" = "Napravi iz boje"
"Dart constants for Flutter…" = "Dart konstante za Flutter…"
"Can edit" = "Može uređivati"
"Can't read this library: {e}" = "Nije moguće pročitati ovu biblioteku: {e}"
@@ -823,6 +824,7 @@ status = "draft — needs native review"
"Main menu" = "Glavni meni"
"Make a component" = "Napravi komponentu"
"Make slot" = "Napravi slot"
"New collection" = "Nova kolekcija"
"Pasted design layers" = "Zalijepljeni slojevi dizajna"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Zalijepljeni slojevi dizajna; međuspremnik nije prenio slike za: {layers}"
"Scroll wheel zoom" = "Zumiranje kotačićem miša"
+9
View File
@@ -14,6 +14,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commity: {commits}, +{added} / −{deleted} řádků (Δ {delta}), +{bin_added} / −{bin_deleted} binárních souborů, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekce tohoto souboru s těmito režimy a proměnnými: přepište jejich hodnoty pro jednotlivé režimy a přidejte vlastní proměnné"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Soubor .figcraft s tímto názvem už existuje. Nahrazení potvrdíte výběrem jeho úplného názvu."
"A later vote" = "Pozdější hlasování"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Samostatná vrstva se zarovná ke svému rámci: vyberte více vrstev nebo jednu v rámci"
"A responsive product page: hero, features, figures, a quote and a footer" = "Responzivní produktová stránka: úvodní blok, funkce, čísla, citace a zápatí"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Pružina se pohybuje až do ustálení: délku určuje její hmotnost, tuhost a tlumení"
@@ -104,6 +105,7 @@ status = "draft — needs native review"
"Alphabetical" = "Abecedně"
"Alt+drag" = "Alt+tažení"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytický přehled na mřížce: čísla, vektorové grafy a tabulka"
"An earlier vote" = "Dřívější hlasování"
"An invite link that opens FigCraft and joins" = "Odkaz s pozvánkou, který otevře FigCraft a připojí k relaci"
"Anchor" = "Kotva"
"Android" = "Android"
@@ -341,6 +343,7 @@ status = "draft — needs native review"
"Create component" = "Vytvořit komponentu"
"Create component property" = "Vytvořit vlastnost komponenty"
"Create components, styles or variables to publish them as a library." = "Vytvořte komponenty, styly nebo proměnné a publikujte je jako knihovnu."
"Create from color" = "Vytvořit z barvy"
"Create property…" = "Vytvořit vlastnost…"
"Create style" = "Vytvořit styl"
"Create style from the selected text" = "Vytvořit styl z vybraného textu"
@@ -377,6 +380,7 @@ status = "draft — needs native review"
"Delete page" = "Smazat stránku"
"Delete preset" = "Smazat předvolbu"
"Delete property" = "Smazat vlastnost"
"Delete results" = "Smazat výsledky"
"Delete the style (layers keep their values)" = "Smazat styl (vrstvy si zachovají hodnoty)"
"Delete thread" = "Smazat vlákno"
"Delete variable" = "Smazat proměnnou"
@@ -764,8 +768,10 @@ status = "draft — needs native review"
"Main menu" = "Hlavní nabídka"
"Make a component" = "Vytvořit komponentu"
"Make slot" = "Vytvořit slot"
"New collection" = "Nová kolekce"
"Pasted design layers" = "Vložené návrhové vrstvy"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Návrhové vrstvy vloženy; schránka neobsahovala obrázky těchto vrstev: {layers}"
"Remove these results from the file" = "Odebrat tyto výsledky ze souboru"
"Scroll wheel zoom" = "Přibližování kolečkem myši"
"Slot name" = "Název slotu"
"Mar" = "března"
@@ -901,6 +907,8 @@ status = "draft — needs native review"
"Not read yet" = "Dosud nepřečteno"
"Check for updates" = "Zkontrolovat aktualizace"
"Not checked yet" = "Dosud nezkontrolováno"
"Someone who left" = "Někdo, kdo odešel"
"Take the results off the board; they stay in the file" = "Odebrat výsledky z tabule; zůstanou v souboru"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tento soubor používá týmové knihovny na serverech, které jste tu dosud nepoužili. Nekontaktují se, dokud je nezkontrolujete."
"Not shared" = "Nesdíleno"
"Note {n}" = "Poznámka {n}"
@@ -1727,6 +1735,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · na serveru"
"{name} · published version {v}" = "{name} · publikovaná verze {v}"
"{name} · version {v} on the server" = "{name} · verze {v} na serveru"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, v živé relaci"
"{name}, online, open only to you" = "{name}, online, přístupný pouze vám"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, vybráno"
+9
View File
@@ -18,6 +18,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} Commits, +{added} / −{deleted} Zeilen (Δ {delta}), +{bin_added} / −{bin_deleted} Binärdateien, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Eine Sammlung dieser Datei mit diesen Modi und Variablen: Überschreiben Sie ihre Werte je Modus und fügen Sie eigene Variablen hinzu"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Eine .figcraft-Datei mit diesem Namen existiert bereits. Wählen Sie ihren vollständigen Namen, um das Ersetzen zu bestätigen."
"A later vote" = "Eine spätere Abstimmung"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Eine einzelne Ebene richtet sich an ihrem Rahmen aus: Wählen Sie weitere Ebenen oder eine Ebene in einem Rahmen"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Eine Feder läuft, bis sie zur Ruhe kommt: Masse, Steifigkeit und Dämpfung bestimmen die Dauer"
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Eine Tabelle hat höchstens {rows} Zeilen und {columns} Spalten: Der Rest wurde nicht eingefügt"
@@ -104,6 +105,7 @@ status = "draft — needs native review"
"Along the top" = "Entlang der Oberkante"
"Alpha" = "Alpha"
"Alt+drag" = "Alt+Ziehen"
"An earlier vote" = "Eine frühere Abstimmung"
"An invite link that opens FigCraft and joins" = "Ein Einladungslink, der FigCraft öffnet und beitritt"
"Anchor" = "Anker"
"Android" = "Android"
@@ -342,6 +344,7 @@ status = "draft — needs native review"
"Create component" = "Komponente erstellen"
"Create component property" = "Komponenteneigenschaft erstellen"
"Create components, styles or variables to publish them as a library." = "Erstellen Sie Komponenten, Stile oder Variablen, um sie als Bibliothek zu veröffentlichen."
"Create from color" = "Aus Farbe erstellen"
"Create property…" = "Eigenschaft erstellen…"
"Create style" = "Stil erstellen"
"Create style from the selected text" = "Stil aus dem ausgewählten Text erstellen"
@@ -374,6 +377,7 @@ status = "draft — needs native review"
"Delete page" = "Seite löschen"
"Delete preset" = "Vorgabe löschen"
"Delete property" = "Eigenschaft löschen"
"Delete results" = "Ergebnisse löschen"
"Delete the style (layers keep their values)" = "Stil löschen (Ebenen behalten ihre Werte)"
"Delete thread" = "Thread löschen"
"Delete variable" = "Variable löschen"
@@ -749,9 +753,11 @@ status = "draft — needs native review"
"Main component deleted" = "Hauptkomponente gelöscht"
"Main menu" = "Hauptmenü"
"Make slot" = "Slot erstellen"
"New collection" = "Neue Sammlung"
"Nobody shares a file there now." = "Dort teilt gerade niemand eine Datei."
"Pasted design layers" = "Eingefügte Designebenen"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designebenen eingefügt; die Zwischenablage enthielt die Bilder von {layers} nicht"
"Remove these results from the file" = "Diese Ergebnisse aus der Datei entfernen"
"Scroll wheel zoom" = "Mit dem Mausrad zoomen"
"Shared by your team on {server}" = "Von Ihrem Team geteilt auf {server}"
"Slot name" = "Slot-Name"
@@ -883,6 +889,8 @@ status = "draft — needs native review"
"Not read yet" = "Noch nicht gelesen"
"Check for updates" = "Nach Updates suchen"
"Not checked yet" = "Noch nicht geprüft"
"Someone who left" = "Jemand, der gegangen ist"
"Take the results off the board; they stay in the file" = "Ergebnisse vom Board nehmen; sie bleiben in der Datei"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Diese Datei nutzt Teambibliotheken auf Servern, die Sie hier noch nicht verwendet haben. Diese Server werden erst kontaktiert, wenn Sie die Bibliotheken prüfen."
"Not shared" = "Nicht geteilt"
"Note {n}" = "Notiz {n}"
@@ -1694,6 +1702,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · auf einem Server"
"{name} · published version {v}" = "{name} · veröffentlichte Version {v}"
"{name} · version {v} on the server" = "{name} · Version {v} auf dem Server"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, in einer Live-Sitzung"
"{name}, online, open only to you" = "{name}, online, nur für Sie zugänglich"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, ausgewählt"
+9
View File
@@ -17,6 +17,7 @@ name = "English"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "A .figcraft file with this name already exists. Choose its full name to confirm replacement."
"A later vote" = "A later vote"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "A layer on its own aligns to its frame: select more layers, or one in a frame"
"A responsive product page: hero, features, figures, a quote and a footer" = "A responsive product page: hero, features, figures, a quote and a footer"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "A spring plays until it settles: its mass, stiffness and damping set how long"
@@ -111,6 +112,7 @@ name = "English"
"Alphabetical" = "Alphabetical"
"Alt+drag" = "Alt+drag"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "An analytics screen on a grid: figures, charts drawn from vectors and a table"
"An earlier vote" = "An earlier vote"
"An invite link that opens FigCraft and joins" = "An invite link that opens FigCraft and joins"
"Anchor" = "Anchor"
"Android" = "Android"
@@ -242,6 +244,7 @@ name = "English"
"Copy browser link" = "Copy browser link"
"Couldn't reach {server} for its team list." = "Couldn't reach {server} for its team list."
"CSS custom properties…" = "CSS custom properties…"
"Create from color" = "Create from color"
"Dart constants for Flutter…" = "Dart constants for Flutter…"
"Calculated from other variables; it follows them" = "Calculated from other variables; it follows them"
"Can edit" = "Can edit"
@@ -412,6 +415,7 @@ name = "English"
"Delete page" = "Delete page"
"Delete preset" = "Delete preset"
"Delete property" = "Delete property"
"Delete results" = "Delete results"
"Delete the style (layers keep their values)" = "Delete the style (layers keep their values)"
"Delete thread" = "Delete thread"
"Delete variable" = "Delete variable"
@@ -826,9 +830,11 @@ name = "English"
"Main menu" = "Main menu"
"Make a component" = "Make a component"
"Make slot" = "Make slot"
"New collection" = "New collection"
"Nobody shares a file there now." = "Nobody shares a file there now."
"Pasted design layers" = "Pasted design layers"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}"
"Remove these results from the file" = "Remove these results from the file"
"Scroll wheel zoom" = "Scroll wheel zoom"
"Shared by your team on {server}" = "Shared by your team on {server}"
"Slot name" = "Slot name"
@@ -1302,6 +1308,8 @@ name = "English"
"Search files and templates" = "Search files and templates"
"Search results" = "Search results"
"Search fonts" = "Search fonts"
"Someone who left" = "Someone who left"
"Take the results off the board; they stay in the file" = "Take the results off the board; they stay in the file"
"Use fonts installed on this computer…" = "Use fonts installed on this computer…"
"Search layers" = "Search layers"
"Search pages" = "Search pages"
@@ -1863,6 +1871,7 @@ name = "English"
"{name} · on a server" = "{name} · on a server"
"{name} · published version {v}" = "{name} · published version {v}"
"{name} · version {v} on the server" = "{name} · version {v} on the server"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, in a live session"
"{name}, online, open only to you" = "{name}, online, open only to you"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} by {h}, selected"
+9
View File
@@ -8,6 +8,7 @@ name = "Español"
status = "draft — needs native review"
[ui]
"A later vote" = "Una votación posterior"
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Una tabla admite como máximo {rows} filas y {columns} columnas: el resto no se pegó"
"Add a connected shape" = "Añadir una forma conectada"
"Add a minute" = "Añadir un minuto"
@@ -19,6 +20,7 @@ status = "draft — needs native review"
"Along the left" = "A lo largo del borde izquierdo"
"Along the right" = "A lo largo del borde derecho"
"Along the top" = "A lo largo del borde superior"
"An earlier vote" = "Una votación anterior"
"Android Vector Drawable" = "Android Vector Drawable"
"Best effort" = "Mejor aproximación"
"Between the selected layers" = "Entre las capas seleccionadas"
@@ -32,10 +34,16 @@ status = "draft — needs native review"
"Connector" = "Conector"
"Couldn't download the variables" = "No se pudieron descargar las variables"
"Couldn't download {name}" = "No se pudo descargar {name}"
"Create from color" = "Crear a partir del color"
"New collection" = "Nueva colección"
"Delete results" = "Eliminar resultados"
"Pasted design layers" = "Capas de diseño pegadas"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Capas de diseño pegadas; el portapapeles no traía las imágenes de: {layers}"
"Relative to" = "Relativa a"
"Remove these results from the file" = "Quitar estos resultados del archivo"
"Scroll wheel zoom" = "Zoom con la rueda del ratón"
"Someone who left" = "Alguien que se fue"
"Take the results off the board; they stay in the file" = "Quitar los resultados del tablero; se quedan en el archivo"
"Trigger layer" = "Capa que la abre"
"Whole screen" = "Pantalla completa"
@@ -1810,6 +1818,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · en un servidor"
"{name} · published version {v}" = "{name} · versión publicada {v}"
"{name} · version {v} on the server" = "{name} · versión {v} en el servidor"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, en una sesión en vivo"
"{name}, online, open only to you" = "{name}, en línea, abierto solo para ti"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} por {h}, seleccionado"
+9
View File
@@ -19,6 +19,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR:t {prs}, muutokset {commits}, rivit +{added} / −{deleted} (Δ {delta}), binääriresurssit +{bin_added} / −{bin_deleted}, {first} – {last}"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Tämänniminen .figcraft-tiedosto on jo olemassa. Vahvista korvaaminen valitsemalla sen koko nimi."
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Tämän tiedoston kokoelma, joka sisältää nämä tilat ja muuttujat. Voit ohittaa niiden arvot tilakohtaisesti ja lisätä omia muuttujia."
"A later vote" = "Myöhempi äänestys"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Yksittäinen taso tasataan kehykseensä. Valitse useampia tasoja tai yksi kehyksen sisällä oleva taso."
"A responsive product page: hero, features, figures, a quote and a footer" = "Mukautuva tuotesivu: pääosio, ominaisuudet, tunnusluvut, lainaus ja alatunniste"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Jousi liikkuu, kunnes se asettuu. Sen massa, jäykkyys ja vaimennus määräävät keston."
@@ -113,6 +114,7 @@ status = "draft — needs native review"
"Alphabetical" = "Aakkosjärjestys"
"Alt+drag" = "Alt+drag"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytiikkanäkymä ruudukossa: tunnuslukuja, vektoreista piirrettyjä kaavioita ja taulukko"
"An earlier vote" = "Aiempi äänestys"
"An invite link that opens FigCraft and joins" = "Kutsulinkki, joka avaa FigCraftin ja liittyy istuntoon"
"Anchor" = "Ankkuri"
"Android" = "Android"
@@ -374,6 +376,7 @@ status = "draft — needs native review"
"Create component" = "Luo komponentti"
"Create component property" = "Luo komponentin ominaisuus"
"Create components, styles or variables to publish them as a library." = "Luo komponentteja, tyylejä tai muuttujia, jotta voit julkaista ne kirjastona."
"Create from color" = "Luo väristä"
"Create property…" = "Luo ominaisuus…"
"Create style" = "Luo tyyli"
"Create style from the selected text" = "Luo tyyli valitusta tekstistä"
@@ -411,6 +414,7 @@ status = "draft — needs native review"
"Delete page" = "Poista sivu"
"Delete preset" = "Poista esiasetus"
"Delete property" = "Poista ominaisuus"
"Delete results" = "Poista tulokset"
"Delete the style (layers keep their values)" = "Poista tyyli (tasojen arvot säilyvät)"
"Delete thread" = "Poista keskustelu"
"Delete variable" = "Poista muuttuja"
@@ -826,8 +830,10 @@ status = "draft — needs native review"
"Make a component" = "Luo komponentti"
"Make slot" = "Luo sisältöpaikka"
"Nobody shares a file there now." = "Kukaan ei jaa siellä nyt tiedostoa."
"New collection" = "Uusi kokoelma"
"Pasted design layers" = "Liitetyt suunnittelutasot"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Suunnittelutasot liitetty; leikepöydällä ei ollut näiden kuvia: {layers}"
"Remove these results from the file" = "Poista nämä tulokset tiedostosta"
"Scroll wheel zoom" = "Zoomaus hiiren rullalla"
"Shared by your team on {server}" = "Tiimisi jakama palvelimella {server}"
"Slot name" = "Sisältöpaikan nimi"
@@ -971,6 +977,8 @@ status = "draft — needs native review"
"Check for updates…" = "Tarkista päivitykset…"
"Checking for updates…" = "Tarkistetaan päivityksiä…"
"Not checked yet" = "Ei vielä tarkistettu"
"Someone who left" = "Joku, joka lähti"
"Take the results off the board; they stay in the file" = "Poista tulokset taululta; ne jäävät tiedostoon"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tämä tiedosto käyttää tiimikirjastoja palvelimilla, joita et ole käyttänyt täällä. Niihin ei oteta yhteyttä ennen kuin tarkistat ne."
"Not shared" = "Ei jaettu"
"Note {n}" = "Huomautus {n}"
@@ -1865,6 +1873,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · palvelimella"
"{name} · published version {v}" = "{name} · julkaistu versio {v}"
"{name} · version {v} on the server" = "{name} · versio {v} palvelimella"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, reaaliaikaisessa istunnossa"
"{name}, online, open only to you" = "{name}, verkossa, vain sinun käytössäsi"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, valittu"
+9
View File
@@ -18,6 +18,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login} : {prs} PR, {commits} commits, +{added} / −{deleted} lignes (Δ {delta}), +{bin_added} / −{bin_deleted} ressources binaires, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement."
"A later vote" = "Un vote plus récent"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre"
"A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée"
@@ -109,6 +110,7 @@ status = "draft — needs native review"
"Alphabetical" = "Alphabétique"
"Alt+drag" = "Alt+glisser"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau"
"An earlier vote" = "Un vote plus ancien"
"An invite link that opens FigCraft and joins" = "Un lien d’invitation qui ouvre FigCraft et rejoint la session"
"Anchor" = "Ancre"
"Android" = "Android"
@@ -351,6 +353,7 @@ status = "draft — needs native review"
"Create component" = "Créer un composant"
"Create component property" = "Créer une propriété de composant"
"Create components, styles or variables to publish them as a library." = "Créez des composants, des styles ou des variables pour les publier en tant que bibliothèque."
"Create from color" = "Créer à partir de la couleur"
"Create property…" = "Créer une propriété…"
"Create style" = "Créer un style"
"Create style from the selected text" = "Créer un style à partir du texte sélectionné"
@@ -387,6 +390,7 @@ status = "draft — needs native review"
"Delete page" = "Supprimer la page"
"Delete preset" = "Supprimer le préréglage"
"Delete property" = "Supprimer la propriété"
"Delete results" = "Supprimer les résultats"
"Delete the style (layers keep their values)" = "Supprimer le style (les calques conservent leurs valeurs)"
"Delete thread" = "Supprimer le fil"
"Delete variable" = "Supprimer la variable"
@@ -781,8 +785,10 @@ status = "draft — needs native review"
"Main menu" = "Menu principal"
"Make a component" = "Créer un composant"
"Make slot" = "Créer un emplacement"
"New collection" = "Nouvelle collection"
"Pasted design layers" = "Calques de design collés"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Calques de design collés ; le presse-papiers ne contenait pas les images de : {layers}"
"Remove these results from the file" = "Retirer ces résultats du fichier"
"Scroll wheel zoom" = "Zoom avec la molette"
"Slot name" = "Nom de l'emplacement"
"Mar" = "mars"
@@ -919,6 +925,8 @@ status = "draft — needs native review"
"Not read yet" = "Pas encore lu"
"Check for updates" = "Rechercher des mises à jour"
"Not checked yet" = "Pas encore vérifiée"
"Someone who left" = "Quelqu'un qui est parti"
"Take the results off the board; they stay in the file" = "Retirer les résultats du tableau ; ils restent dans le fichier"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ce fichier utilise des bibliothèques d’équipe sur des serveurs que vous n’avez pas utilisés ici. Ils ne sont contactés que lorsque vous les vérifiez."
"Not shared" = "Non partagé"
"Note {n}" = "Note {n}"
@@ -1770,6 +1778,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · sur un serveur"
"{name} · published version {v}" = "{name} · version publiée {v}"
"{name} · version {v} on the server" = "{name} · version {v} sur le serveur"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, dans une session en direct"
"{name}, online, open only to you" = "{name}, en ligne, accessible à vous seul"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} sur {h}, sélectionné"
+9
View File
@@ -8,12 +8,20 @@ name = "Bahasa Indonesia"
status = "draft — needs native review"
[ui]
"A later vote" = "Pemungutan suara yang lebih baru"
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabel memuat paling banyak {rows} baris dan {columns} kolom: sisanya tidak ditempel"
"Add a connected shape" = "Tambahkan bentuk yang terhubung"
"Create from color" = "Buat dari warna"
"New collection" = "Koleksi baru"
"An earlier vote" = "Pemungutan suara yang lebih lama"
"Delete results" = "Hapus hasil"
"Pasted design layers" = "Lapisan desain yang ditempel"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Lapisan desain ditempel; papan klip tidak membawa gambar dari: {layers}"
"Relative to" = "Relatif terhadap"
"Remove these results from the file" = "Hapus hasil ini dari file"
"Scroll wheel zoom" = "Zoom dengan roda gulir"
"Someone who left" = "Seseorang yang sudah keluar"
"Take the results off the board; they stay in the file" = "Hapus hasil dari papan; tetap tersimpan di file"
"Trigger layer" = "Layer pemicu"
"Whole screen" = "Seluruh layar"
@@ -1731,6 +1739,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · di server"
"{name} · published version {v}" = "{name} · versi terpublikasi {v}"
"{name} · version {v} on the server" = "{name} · versi {v} di server"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, dalam sesi langsung"
"{name}, online, open only to you" = "{name}, online, hanya terbuka untuk Anda"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} x {h}, dipilih"
+9
View File
@@ -17,6 +17,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} righe (Δ {delta}), +{bin_added} / −{bin_deleted} asset binari, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una collezione di questo file con queste modalità e variabili: sovrascrivi i loro valori per modalità e aggiungi variabili proprie"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Esiste già un file .figcraft con questo nome. Scegli il nome completo per confermare la sostituzione."
"A later vote" = "Una votazione successiva"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un livello da solo si allinea al proprio frame: seleziona più livelli, oppure uno dentro un frame"
"A responsive product page: hero, features, figures, a quote and a footer" = "Una pagina prodotto responsive: hero, funzionalità, figure, una citazione e un footer"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Una molla si muove finché non si assesta: massa, rigidità e smorzamento ne determinano la durata"
@@ -100,6 +101,7 @@ status = "draft — needs native review"
"Alphabetical" = "Alfabetico"
"Alt+drag" = "Alt+trascina"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Una schermata di analisi su una griglia: cifre, grafici disegnati con vettori e una tabella"
"An earlier vote" = "Una votazione precedente"
"An invite link that opens FigCraft and joins" = "Un link di invito che apre FigCraft e si unisce"
"Anchor" = "Ancora"
"Android" = "Android"
@@ -335,6 +337,7 @@ status = "draft — needs native review"
"Create component" = "Crea componente"
"Create component property" = "Crea proprietà del componente"
"Create components, styles or variables to publish them as a library." = "Crea componenti, stili o variabili per pubblicarli come libreria."
"Create from color" = "Crea dal colore"
"Create property…" = "Crea proprietà…"
"Create style" = "Crea stile"
"Create style from the selected text" = "Crea stile dal testo selezionato"
@@ -371,6 +374,7 @@ status = "draft — needs native review"
"Delete page" = "Elimina pagina"
"Delete preset" = "Elimina preset"
"Delete property" = "Elimina proprietà"
"Delete results" = "Elimina risultati"
"Delete the style (layers keep their values)" = "Elimina lo stile (i livelli mantengono i propri valori)"
"Delete thread" = "Elimina discussione"
"Delete variable" = "Elimina variabile"
@@ -755,8 +759,10 @@ status = "draft — needs native review"
"Main menu" = "Menu principale"
"Make a component" = "Crea un componente"
"Make slot" = "Crea slot"
"New collection" = "Nuova collezione"
"Pasted design layers" = "Livelli di design incollati"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Livelli di design incollati; gli appunti non contenevano le immagini di: {layers}"
"Remove these results from the file" = "Rimuovi questi risultati dal file"
"Scroll wheel zoom" = "Zoom con la rotella del mouse"
"Slot name" = "Nome dello slot"
"Mar" = "mar"
@@ -890,6 +896,8 @@ status = "draft — needs native review"
"Not read yet" = "Non ancora letto"
"Check for updates" = "Verifica aggiornamenti"
"Not checked yet" = "Non ancora verificata"
"Someone who left" = "Qualcuno che è uscito"
"Take the results off the board; they stay in the file" = "Togli i risultati dalla lavagna; restano nel file"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Questo file usa librerie del team su server che non hai usato qui. Non vengono contattati finché non le verifichi."
"Not shared" = "Non condiviso"
"Note {n}" = "Nota {n}"
@@ -1707,6 +1715,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · su un server"
"{name} · published version {v}" = "{name} · versione {v} pubblicata"
"{name} · version {v} on the server" = "{name} · versione {v} sul server"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, in una sessione live"
"{name}, online, open only to you" = "{name}, online, aperto solo a te"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} per {h}, selezionato"
+9
View File
@@ -20,6 +20,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "この名前の .figcraft ファイルは既に存在します。置き換えるには、拡張子まで含む名前を選択。"
"A later vote" = "後の投票"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択"
"A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる"
@@ -110,6 +111,7 @@ status = "draft — needs native review"
"Alphabetical" = "名前順"
"Alt+drag" = "Alt+ドラッグ"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "グリッドに沿った分析画面:数値、ベクターで描いたグラフ、表"
"An earlier vote" = "前の投票"
"An invite link that opens FigCraft and joins" = "FigCraft を開いて参加する招待リンク"
"Anchor" = "アンカー"
"Android" = "Android"
@@ -349,6 +351,7 @@ status = "draft — needs native review"
"Create component" = "コンポーネントを作成"
"Create component property" = "コンポーネントプロパティを作成"
"Create components, styles or variables to publish them as a library." = "コンポーネント、スタイル、変数を作成すると、ライブラリとして公開できる。"
"Create from color" = "色から作成"
"Create property…" = "プロパティを作成…"
"Create style" = "スタイルを作成"
"Create style from the selected text" = "選択したテキストからスタイルを作成"
@@ -385,6 +388,7 @@ status = "draft — needs native review"
"Delete page" = "ページを削除"
"Delete preset" = "プリセットを削除"
"Delete property" = "プロパティを削除"
"Delete results" = "結果を削除"
"Delete the style (layers keep their values)" = "スタイルを削除(レイヤーの値は保持)"
"Delete thread" = "スレッドを削除"
"Delete variable" = "変数を削除"
@@ -775,8 +779,10 @@ status = "draft — needs native review"
"Main menu" = "メインメニュー"
"Make a component" = "コンポーネントを作成"
"Make slot" = "スロットにする"
"New collection" = "新しいコレクション"
"Pasted design layers" = "貼り付けたデザインレイヤー"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "デザインレイヤーを貼り付けました。クリップボードに次の画像がありませんでした: {layers}"
"Remove these results from the file" = "この結果をファイルから削除します"
"Scroll wheel zoom" = "スクロールホイールでズーム"
"Slot name" = "スロット名"
"Mar" = "3月"
@@ -912,6 +918,8 @@ status = "draft — needs native review"
"Not read yet" = "未読"
"Check for updates" = "アップデートを確認"
"Not checked yet" = "未確認"
"Someone who left" = "退出した人"
"Take the results off the board; they stay in the file" = "ボードから結果を外します(ファイルには残ります)"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "このファイルは、ここで使ったことのないサーバー上のチームライブラリを使う。確認するまでそれらのサーバーには接続しない。"
"Not shared" = "共有していない"
"Note {n}" = "メモ {n}"
@@ -1751,6 +1759,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · サーバー上"
"{name} · published version {v}" = "{name} · 公開バージョン {v}"
"{name} · version {v} on the server" = "{name} · サーバー上のバージョン {v}"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}、ライブセッション中"
"{name}, online, open only to you" = "{name}、オンライン、開けるのはあなただけ"
"{name}, {kind}, {w} by {h}, selected" = "{name}、{kind}、{w} × {h}、選択中"
+9
View File
@@ -20,6 +20,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, კომიტი: {commits}, +{added} / −{deleted} სტრიქონი (Δ {delta}), +{bin_added} / −{bin_deleted} ბინარული რესურსი, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "ამ ფაილის კოლექცია ამ რეჟიმებითა და ცვლადებით: შეცვალეთ მათი მნიშვნელობები თითოეული რეჟიმისთვის და დაამატეთ საკუთარი ცვლადები"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "ამ სახელით .figcraft ფაილი უკვე არსებობს. ჩანაცვლების დასადასტურებლად აირჩიეთ მისი სრული სახელი."
"A later vote" = "მოგვიანებითი კენჭისყრა"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე"
"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი ყველა ძირითადი ბლოკით"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას"
@@ -112,6 +113,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Alphabetical" = "ანბანის მიხედვით"
"Alt+drag" = "Alt+გადათრევა"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკა ბადეზე: ციფრები, დიაგრამები და ცხრილი"
"An earlier vote" = "ადრინდელი კენჭისყრა"
"An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას"
"Anchor" = "საყრდენი"
"Android" = "Android"
@@ -356,6 +358,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Create component" = "კომპონენტის შექმნა"
"Create component property" = "კომპონენტის თვისების შექმნა"
"Create components, styles or variables to publish them as a library." = "შექმენით კომპონენტები, სტილები ან ცვლადები, რომ ბიბლიოთეკად გამოაქვეყნოთ."
"Create from color" = "ფერიდან შექმნა"
"Create property…" = "თვისების შექმნა…"
"Create style" = "სტილის შექმნა"
"Create style from the selected text" = "სტილის შექმნა მონიშნული ტექსტიდან"
@@ -392,6 +395,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Delete page" = "გვერდის წაშლა"
"Delete preset" = "პრესეტის წაშლა"
"Delete property" = "თვისების წაშლა"
"Delete results" = "შედეგების წაშლა"
"Delete the style (layers keep their values)" = "სტილის წაშლა (შრეები ინარჩუნებს მნიშვნელობებს)"
"Delete thread" = "კომენტარისა და პასუხების წაშლა"
"Delete variable" = "ცვლადის წაშლა"
@@ -788,8 +792,10 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Main menu" = "მთავარი მენიუ"
"Make a component" = "შექმენით კომპონენტი"
"Make slot" = "სლოტად გადაქცევა"
"New collection" = "ახალი კოლექცია"
"Pasted design layers" = "ჩასმული დიზაინის ფენები"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "დიზაინის ფენები ჩაისვა; ბუფერში არ იყო შემდეგის სურათები: {layers}"
"Remove these results from the file" = "ამ შედეგების ფაილიდან წაშლა"
"Scroll wheel zoom" = "მასშტაბირება გადახვევის ბორბლით"
"Slot name" = "სლოტის სახელი"
"Mar" = "მარ"
@@ -928,6 +934,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Not read yet" = "ჯერ არ არის წაკითხული"
"Check for updates" = "განახლებების შემოწმება"
"Not checked yet" = "ჯერ არ არის შემოწმებული"
"Someone who left" = "ვინც წავიდა"
"Take the results off the board; they stay in the file" = "შედეგების დაფიდან მოხსნა; ფაილში დარჩება"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ეს ფაილი იყენებს გუნდის ბიბლიოთეკებს სერვერებზე, რომლებიც აქ არ გამოგიყენებიათ. მათ არ დავუკავშირდებით, სანამ არ შეამოწმებთ."
"Not shared" = "არ არის გაზიარებული"
"Note {n}" = "შენიშვნა {n}"
@@ -1790,6 +1798,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"{name} · on a server" = "{name} · სერვერზე"
"{name} · published version {v}" = "{name} · გამოქვეყნებული ვერსია {v}"
"{name} · version {v} on the server" = "{name} · ვერსია {v} სერვერზე"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, ცოცხალ სესიაში"
"{name}, online, open only to you" = "{name}, ონლაინ, ღიაა მხოლოდ თქვენთვის"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, მონიშნულია"
+9
View File
@@ -19,6 +19,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR {prs}개, 커밋 {commits}개, +{added} / −{deleted}줄 (Δ {delta}), 바이너리 에셋 +{bin_added} / −{bin_deleted}, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "이 파일의 컬렉션과 그 모드 및 변수: 모드별로 값을 재정의하고 자체 변수를 추가할 수 있음"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "이 이름의 .figcraft 파일이 이미 있습니다. 바꾸려면 확장자까지 포함한 전체 이름을 선택하세요."
"A later vote" = "이후 투표"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "레이어 하나는 프레임에 맞춰 정렬됩니다: 레이어를 더 선택하거나 프레임 안의 레이어를 선택하세요"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "스프링은 멈출 때까지 재생됨. 길이는 질량, 강성, 감쇠로 결정됨"
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "표는 최대 {rows}행 {columns}열입니다. 나머지는 붙여넣지 않았습니다"
@@ -104,6 +105,7 @@ status = "draft — needs native review"
"Along the top" = "위쪽 가장자리를 따라"
"Alpha" = "알파"
"Alt+drag" = "Alt+드래그"
"An earlier vote" = "이전 투표"
"An invite link that opens FigCraft and joins" = "FigCraft를 열고 참여하는 초대 링크"
"Anchor" = "앵커"
"Android" = "Android"
@@ -338,6 +340,7 @@ status = "draft — needs native review"
"Create component" = "컴포넌트 만들기"
"Create component property" = "컴포넌트 속성 만들기"
"Create components, styles or variables to publish them as a library." = "컴포넌트, 스타일 또는 변수를 만들어 라이브러리로 게시하세요."
"Create from color" = "색상으로 만들기"
"Create property…" = "속성 만들기…"
"Create style" = "스타일 만들기"
"Create style from the selected text" = "선택한 텍스트로 스타일 만들기"
@@ -370,6 +373,7 @@ status = "draft — needs native review"
"Delete page" = "페이지 삭제"
"Delete preset" = "프리셋 삭제"
"Delete property" = "속성 삭제"
"Delete results" = "결과 삭제"
"Delete the style (layers keep their values)" = "스타일 삭제(레이어는 값을 유지함)"
"Delete thread" = "스레드 삭제"
"Delete variable" = "변수 삭제"
@@ -739,8 +743,10 @@ status = "draft — needs native review"
"Main component deleted" = "메인 컴포넌트가 삭제됨"
"Main menu" = "메인 메뉴"
"Make slot" = "슬롯으로 만들기"
"New collection" = "새 컬렉션"
"Pasted design layers" = "붙여넣은 디자인 레이어"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "디자인 레이어를 붙여넣었습니다. 클립보드에 다음 이미지가 없었습니다: {layers}"
"Remove these results from the file" = "이 결과를 파일에서 삭제합니다"
"Scroll wheel zoom" = "스크롤 휠로 확대/축소"
"Slot name" = "슬롯 이름"
"Margin" = "여백"
@@ -871,6 +877,8 @@ status = "draft — needs native review"
"Not read yet" = "아직 읽지 않음"
"Check for updates" = "업데이트 확인"
"Not checked yet" = "아직 확인 안 함"
"Someone who left" = "나간 사람"
"Take the results off the board; they stay in the file" = "보드에서 결과를 내립니다. 파일에는 남습니다"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "이 파일은 여기서 사용한 적 없는 서버의 팀 라이브러리를 사용합니다. 확인하기 전까지는 해당 서버에 연결하지 않습니다."
"Not shared" = "공유 안 함"
"Note {n}" = "메모 {n}"
@@ -1676,6 +1684,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · 서버에 있음"
"{name} · published version {v}" = "{name} · 게시된 버전 {v}"
"{name} · version {v} on the server" = "{name} · 서버의 버전 {v}"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, 라이브 세션 중"
"{name}, online, open only to you" = "{name}, 온라인, 나만 열 수 있음"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, 선택됨"
+9
View File
@@ -19,6 +19,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} regels (Δ {delta}), +{bin_added} / −{bin_deleted} binaire assets, {first} – {last}"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Er bestaat al een .figcraft-bestand met deze naam. Kies de volledige naam om de vervanging te bevestigen."
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Een verzameling van dit bestand met deze modi en variabelen: overschrijf hun waarden per modus en voeg er eigen variabelen toe"
"A later vote" = "Een latere stemming"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Een losse laag wordt uitgelijnd op zijn frame: selecteer meer lagen, of één in een frame"
"A responsive product page: hero, features, figures, a quote and a footer" = "Een responsive productpagina: hero, features, afbeeldingen, een citaat en een footer"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Een veer speelt tot hij tot rust komt: zijn massa, stijfheid en demping bepalen hoe lang"
@@ -111,6 +112,7 @@ status = "draft — needs native review"
"Alphabetical" = "Alfabetisch"
"Alt+drag" = "Alt+drag"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Een analysescherm op een raster: cijfers, diagrammen getekend met vectoren en een tabel"
"An earlier vote" = "Een eerdere stemming"
"An invite link that opens FigCraft and joins" = "Een uitnodigingslink die FigCraft opent en waarmee je deelneemt"
"Anchor" = "Anker"
"Android" = "Android"
@@ -353,6 +355,7 @@ status = "draft — needs native review"
"Create component" = "Component maken"
"Create component property" = "Componenteigenschap maken"
"Create components, styles or variables to publish them as a library." = "Maak componenten, stijlen of variabelen om ze als bibliotheek te publiceren."
"Create from color" = "Maken van kleur"
"Create property…" = "Eigenschap maken…"
"Create style" = "Stijl maken"
"Create style from the selected text" = "Stijl maken van de geselecteerde tekst"
@@ -389,6 +392,7 @@ status = "draft — needs native review"
"Delete page" = "Pagina verwijderen"
"Delete preset" = "Preset verwijderen"
"Delete property" = "Eigenschap verwijderen"
"Delete results" = "Resultaten verwijderen"
"Delete the style (layers keep their values)" = "Verwijdert de stijl (lagen behouden hun waarden)"
"Delete thread" = "Gesprek verwijderen"
"Delete variable" = "Variabele verwijderen"
@@ -781,8 +785,10 @@ status = "draft — needs native review"
"Main menu" = "Hoofdmenu"
"Make a component" = "Maak een component"
"Make slot" = "Slot maken"
"New collection" = "Nieuwe verzameling"
"Pasted design layers" = "Geplakte ontwerplagen"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Ontwerplagen geplakt; het klembord bevatte de afbeeldingen van {layers} niet"
"Remove these results from the file" = "Deze resultaten uit het bestand verwijderen"
"Scroll wheel zoom" = "Zoomen met scrollwiel"
"Slot name" = "Slotnaam"
"Mar" = "mrt."
@@ -921,6 +927,8 @@ status = "draft — needs native review"
"Not read yet" = "Nog niet gelezen"
"Check for updates" = "Controleren op updates"
"Not checked yet" = "Nog niet gecontroleerd"
"Someone who left" = "Iemand die vertrokken is"
"Take the results off the board; they stay in the file" = "Resultaten van het bord halen; ze blijven in het bestand"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Dit bestand gebruikt teambibliotheken op servers die je hier nog niet hebt gebruikt. Er wordt pas contact mee gemaakt als je ze controleert."
"Not shared" = "Niet gedeeld"
"Note {n}" = "Notitie {n}"
@@ -1766,6 +1774,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · op een server"
"{name} · published version {v}" = "{name} · gepubliceerde versie {v}"
"{name} · version {v} on the server" = "{name} · versie {v} op de server"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, in een live sessie"
"{name}, online, open only to you" = "{name}, online, alleen voor jou toegankelijk"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} bij {h}, geselecteerd"
+9
View File
@@ -17,6 +17,7 @@ name = "Polski"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commitów: {commits}, +{added} / −{deleted} wierszy (Δ {delta}), +{bin_added} / −{bin_deleted} zasobów binarnych, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekcja tego pliku z tymi trybami i zmiennymi: nadpisuj ich wartości dla poszczególnych trybów i dodawaj własne zmienne"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Plik .figcraft o tej nazwie już istnieje. Wybierz pełną nazwę, aby potwierdzić zastąpienie."
"A later vote" = "Późniejsze głosowanie"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Pojedyncza warstwa wyrównuje się do swojej ramki: zaznacz więcej warstw lub warstwę w ramce"
"A responsive product page: hero, features, figures, a quote and a footer" = "Responsywna strona produktu: sekcja hero, funkcje, statystyki, cytat i stopka"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Animacja sprężynowa trwa do ustabilizowania: jej masa, sztywność i tłumienie określają czas"
@@ -109,6 +110,7 @@ name = "Polski"
"Alphabetical" = "Alfabetycznie"
"Alt+drag" = "Alt+przeciągnięcie"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Ekran analityczny na siatce: wykresy wektorowe, liczby i tabela"
"An earlier vote" = "Wcześniejsze głosowanie"
"An invite link that opens FigCraft and joins" = "Link z zaproszeniem otwierający FigCraft i dołączający do sesji"
"Anchor" = "Punkt zaczepienia"
"Android" = "Android"
@@ -352,6 +354,7 @@ name = "Polski"
"Create component" = "Utwórz komponent"
"Create component property" = "Utwórz właściwość komponentu"
"Create components, styles or variables to publish them as a library." = "Twórz komponenty, style lub zmienne, aby opublikować je jako bibliotekę."
"Create from color" = "Utwórz z koloru"
"Create property…" = "Utwórz właściwość…"
"Create style" = "Utwórz styl"
"Create style from the selected text" = "Utwórz styl z zaznaczonego tekstu"
@@ -388,6 +391,7 @@ name = "Polski"
"Delete page" = "Usuń stronę"
"Delete preset" = "Usuń ustawienie"
"Delete property" = "Usuń właściwość"
"Delete results" = "Usuń wyniki"
"Delete the style (layers keep their values)" = "Usuń styl (warstwy zachowają swoje wartości)"
"Delete thread" = "Usuń wątek"
"Delete variable" = "Usuń zmienną"
@@ -783,8 +787,10 @@ name = "Polski"
"Main menu" = "Menu główne"
"Make a component" = "Utwórz komponent"
"Make slot" = "Utwórz gniazdo (slot)"
"New collection" = "Nowa kolekcja"
"Pasted design layers" = "Wklejone warstwy projektu"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Wklejono warstwy projektu; schowek nie zawierał obrazów warstw: {layers}"
"Remove these results from the file" = "Usuń te wyniki z pliku"
"Scroll wheel zoom" = "Powiększanie kółkiem myszy"
"Slot name" = "Nazwa gniazda"
"Mar" = "Mar"
@@ -923,6 +929,8 @@ name = "Polski"
"Not read yet" = "Jeszcze nieprzeczytane"
"Check for updates" = "Sprawdź aktualizacje"
"Not checked yet" = "Jeszcze nie sprawdzono"
"Someone who left" = "Ktoś, kto wyszedł"
"Take the results off the board; they stay in the file" = "Zdejmij wyniki z tablicy; zostają w pliku"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ten plik korzysta z bibliotek zespołu na serwerach, których tu jeszcze nie używano. Nie są kontaktowane, dopóki ich nie sprawdzisz."
"Not shared" = "Nieudostępnione"
"Note {n}" = "Notatka {n}"
@@ -1779,6 +1787,7 @@ name = "Polski"
"{name} · on a server" = "{name} · na serwerze"
"{name} · published version {v}" = "{name} · opublikowana wersja {v}"
"{name} · version {v} on the server" = "{name} · wersja {v} na serwerze"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, w sesji na żywo"
"{name}, online, open only to you" = "{name}, online, otwarty tylko dla Ciebie"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, zaznaczono"
+9
View File
@@ -17,6 +17,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} linhas (Δ {delta}), +{bin_added} / −{bin_deleted} arquivos binários, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Uma coleção deste arquivo com estes modos e variáveis: substitua os valores por modo e adicione variáveis próprias"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Já existe um arquivo .figcraft com este nome. Escolha o nome completo dele para confirmar a substituição."
"A later vote" = "Uma votação posterior"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Uma camada sozinha se alinha ao seu quadro: selecione mais camadas ou uma dentro de um quadro"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Uma mola se move até se estabilizar: sua massa, rigidez e amortecimento definem a duração"
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Uma tabela tem no máximo {rows} linhas e {columns} colunas: o resto não foi colado"
@@ -102,6 +103,7 @@ status = "draft — needs native review"
"Along the top" = "Ao longo da borda superior"
"Alpha" = "Alfa"
"Alt+drag" = "Alt+arrastar"
"An earlier vote" = "Uma votação anterior"
"An invite link that opens FigCraft and joins" = "Um link de convite que abre o FigCraft e entra na sessão"
"Anchor" = "Âncora"
"Android" = "Android"
@@ -337,6 +339,7 @@ status = "draft — needs native review"
"Create component" = "Criar componente"
"Create component property" = "Criar propriedade de componente"
"Create components, styles or variables to publish them as a library." = "Crie componentes, estilos ou variáveis para publicá-los como biblioteca."
"Create from color" = "Criar a partir da cor"
"Create property…" = "Criar propriedade…"
"Create style" = "Criar estilo"
"Create style from the selected text" = "Criar estilo a partir do texto selecionado"
@@ -369,6 +372,7 @@ status = "draft — needs native review"
"Delete page" = "Excluir página"
"Delete preset" = "Excluir predefinição"
"Delete property" = "Excluir propriedade"
"Delete results" = "Excluir resultados"
"Delete the style (layers keep their values)" = "Excluir o estilo (as camadas mantêm seus valores)"
"Delete thread" = "Excluir conversa"
"Delete variable" = "Excluir variável"
@@ -738,8 +742,10 @@ status = "draft — needs native review"
"Main component deleted" = "Componente principal excluído"
"Main menu" = "Menu principal"
"Make slot" = "Transformar em slot"
"New collection" = "Nova coleção"
"Pasted design layers" = "Camadas de design coladas"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Camadas de design coladas; a área de transferência não trouxe as imagens de: {layers}"
"Remove these results from the file" = "Remover estes resultados do arquivo"
"Scroll wheel zoom" = "Zoom com a roda do mouse"
"Slot name" = "Nome do slot"
"Margin" = "Margem"
@@ -870,6 +876,8 @@ status = "draft — needs native review"
"Not read yet" = "Ainda não lido"
"Check for updates" = "Verificar atualizações"
"Not checked yet" = "Ainda não verificada"
"Someone who left" = "Alguém que saiu"
"Take the results off the board; they stay in the file" = "Tirar os resultados do quadro; eles ficam no arquivo"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Este arquivo usa bibliotecas de equipe em servidores que você não usou aqui. Eles só são contatados quando você os verifica."
"Not shared" = "Não compartilhado"
"Note {n}" = "Nota {n}"
@@ -1674,6 +1682,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · em um servidor"
"{name} · published version {v}" = "{name} · versão publicada {v}"
"{name} · version {v} on the server" = "{name} · versão {v} no servidor"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, em uma sessão ao vivo"
"{name}, online, open only to you" = "{name}, online, aberto somente para você"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} por {h}, selecionado"
+9
View File
@@ -17,6 +17,7 @@ name = "Русский"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя."
"A later vote" = "Более позднее голосование"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Отдельный слой выравнивается по своему фрейму: выделите больше слоёв или слой внутри фрейма."
"A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование"
@@ -109,6 +110,7 @@ name = "Русский"
"Alphabetical" = "По алфавиту"
"Alt+drag" = "Alt+перетаскивание"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Экран аналитики на сетке: показатели, векторные диаграммы и таблица"
"An earlier vote" = "Более раннее голосование"
"An invite link that opens FigCraft and joins" = "Ссылка приглашения, которая открывает FigCraft и подключает к сессии"
"Anchor" = "Привязка"
"Android" = "Android"
@@ -352,6 +354,7 @@ name = "Русский"
"Create component" = "Создать компонент"
"Create component property" = "Создать свойство компонента"
"Create components, styles or variables to publish them as a library." = "Создайте компоненты, стили или переменные, чтобы опубликовать их как библиотеку."
"Create from color" = "Создать из цвета"
"Create property…" = "Создать свойство…"
"Create style" = "Создать стиль"
"Create style from the selected text" = "Создать стиль из выделенного текста"
@@ -388,6 +391,7 @@ name = "Русский"
"Delete page" = "Удалить страницу"
"Delete preset" = "Удалить пресет"
"Delete property" = "Удалить свойство"
"Delete results" = "Удалить результаты"
"Delete the style (layers keep their values)" = "Удалить стиль (значения слоёв сохранятся)"
"Delete thread" = "Удалить обсуждение"
"Delete variable" = "Удалить переменную"
@@ -783,8 +787,10 @@ name = "Русский"
"Main menu" = "Главное меню"
"Make a component" = "Создать компонент"
"Make slot" = "Сделать слотом"
"New collection" = "Новая коллекция"
"Pasted design layers" = "Вставленные слои дизайна"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Слои дизайна вставлены; в буфере обмена не было изображений слоёв: {layers}"
"Remove these results from the file" = "Удалить эти результаты из файла"
"Scroll wheel zoom" = "Масштабирование колесом мыши"
"Slot name" = "Имя слота"
"Mar" = "мар."
@@ -923,6 +929,8 @@ name = "Русский"
"Not read yet" = "Ещё не прочитано"
"Check for updates" = "Проверить обновления"
"Not checked yet" = "Ещё не проверено"
"Someone who left" = "Тот, кто вышел"
"Take the results off the board; they stay in the file" = "Убрать результаты с доски; они останутся в файле"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Этот файл использует командные библиотеки на серверах, которые вы здесь не использовали. К ним не обращаются, пока вы их не проверите."
"Not shared" = "Не опубликовано"
"Note {n}" = "Заметка {n}"
@@ -1779,6 +1787,7 @@ name = "Русский"
"{name} · on a server" = "{name} · на сервере"
"{name} · published version {v}" = "{name} · опубликована версия {v}"
"{name} · version {v} on the server" = "{name} · версия {v} на сервере"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, в совместной сессии"
"{name}, online, open only to you" = "{name}, онлайн, доступен только вам"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} на {h}, выделено"
+9
View File
@@ -17,6 +17,7 @@ name = "Svenska"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära resurser, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: ange overrides för deras värden per läge och lägg till egna variabler"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Det finns redan en .figcraft-fil med det här namnet. Välj dess fullständiga namn för att bekräfta att den ersätts."
"A later vote" = "En senare omröstning"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Ett ensamt lager justeras mot sin frame: markera fler lager, eller ett lager i en frame"
"A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge"
@@ -111,6 +112,7 @@ name = "Svenska"
"Alphabetical" = "I bokstavsordning"
"Alt+drag" = "Alt+dra"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "En analysskärm på ett rutnät: figurer, diagram ritade med vektorer och en tabell"
"An earlier vote" = "En tidigare omröstning"
"An invite link that opens FigCraft and joins" = "En inbjudningslänk som öppnar FigCraft och ansluter"
"Anchor" = "Ankare"
"Android" = "Android"
@@ -239,6 +241,7 @@ name = "Svenska"
"Convert to frame" = "Konvertera till frame"
"Convert to section" = "Konvertera till avsnitt"
"CSS custom properties…" = "Anpassade CSS-egenskaper…"
"Create from color" = "Skapa från färg"
"Dart constants for Flutter…" = "Dart-konstanter för Flutter…"
"Can edit" = "Kan redigera"
"Can't read this library: {e}" = "Det går inte att läsa det här biblioteket: {e}"
@@ -408,6 +411,7 @@ name = "Svenska"
"Delete page" = "Radera sida"
"Delete preset" = "Radera förinställning"
"Delete property" = "Radera egenskap"
"Delete results" = "Ta bort resultat"
"Delete the style (layers keep their values)" = "Radera stilen (lagren behåller sina värden)"
"Delete thread" = "Radera tråd"
"Delete variable" = "Radera variabel"
@@ -818,8 +822,10 @@ name = "Svenska"
"Main menu" = "Huvudmeny"
"Make a component" = "Skapa en komponent"
"Make slot" = "Gör till plats"
"New collection" = "Ny samling"
"Pasted design layers" = "Inklistrade designlager"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designlager inklistrade; urklippet innehöll inte bilderna för: {layers}"
"Remove these results from the file" = "Ta bort de här resultaten från filen"
"Scroll wheel zoom" = "Zooma med scrollhjulet"
"Slot name" = "Platsens namn"
"Mar" = "Mar"
@@ -1292,6 +1298,8 @@ name = "Svenska"
"Search files and templates" = "Sök efter filer och mallar"
"Search results" = "Sökresultat"
"Search fonts" = "Sök typsnitt"
"Someone who left" = "Någon som har lämnat"
"Take the results off the board; they stay in the file" = "Ta bort resultaten från tavlan; de finns kvar i filen"
"Use fonts installed on this computer…" = "Använd typsnitt som är installerade på den här datorn…"
"Search layers" = "Sök lager"
"Search pages" = "Sök sidor"
@@ -1849,6 +1857,7 @@ name = "Svenska"
"{name} · on a server" = "{name} · på en server"
"{name} · published version {v}" = "{name} · publicerad version {v}"
"{name} · version {v} on the server" = "{name} · version {v} på servern"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, i en livesession"
"{name}, online, open only to you" = "{name}, online, öppen bara för dig"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} gånger {h}, markerad"
+9
View File
@@ -22,6 +22,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} คอมมิต, +{added} / −{deleted} บรรทัด (Δ {delta}), แอสเซ็ตไบนารี +{bin_added} / −{bin_deleted}, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "คอลเลกชันของไฟล์นี้พร้อมโหมดและตัวแปรเหล่านี้: เขียนทับค่าตามโหมดและเพิ่มตัวแปรของตัวเองได้"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "มีไฟล์ .figcraft ชื่อนี้อยู่แล้ว เลือกชื่อเต็มของไฟล์เพื่อยืนยันการแทนที่"
"A later vote" = "การโหวตครั้งถัดมา"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "เลเยอร์เดี่ยวจะจัดแนวตามเฟรมของมัน: เลือกเลเยอร์เพิ่ม หรือเลือกเลเยอร์ในเฟรม"
"A responsive product page: hero, features, figures, a quote and a footer" = "หน้าสินค้าแบบรีสปอนซีฟ: ฮีโร่ ฟีเจอร์ ตัวเลข คำพูดอ้างอิง และฟุตเตอร์"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "สปริงเล่นจนนิ่ง: มวล ความแข็ง และการหน่วงกำหนดระยะเวลา"
@@ -110,6 +111,7 @@ status = "draft — needs native review"
"Alphabetical" = "ตามตัวอักษร"
"Alt+drag" = "Alt+ลาก"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "หน้าจอวิเคราะห์ข้อมูลบนกริด: ตัวเลข แผนภูมิที่วาดจากเวกเตอร์ และตาราง"
"An earlier vote" = "การโหวตครั้งก่อน"
"An invite link that opens FigCraft and joins" = "ลิงก์เชิญที่เปิด FigCraft และเข้าร่วม"
"Anchor" = "จุดยึด"
"Android" = "Android"
@@ -350,6 +352,7 @@ status = "draft — needs native review"
"Create component" = "สร้างคอมโพเนนต์"
"Create component property" = "สร้างพร็อพเพอร์ตีของคอมโพเนนต์"
"Create components, styles or variables to publish them as a library." = "สร้างคอมโพเนนต์ สไตล์ หรือตัวแปรเพื่อเผยแพร่เป็นไลบรารี"
"Create from color" = "สร้างจากสี"
"Create property…" = "สร้างพร็อพเพอร์ตี…"
"Create style" = "สร้างสไตล์"
"Create style from the selected text" = "สร้างสไตล์จากข้อความที่เลือก"
@@ -386,6 +389,7 @@ status = "draft — needs native review"
"Delete page" = "ลบหน้า"
"Delete preset" = "ลบพรีเซ็ต"
"Delete property" = "ลบพร็อพเพอร์ตี"
"Delete results" = "ลบผลลัพธ์"
"Delete the style (layers keep their values)" = "ลบสไตล์ (เลเยอร์ยังคงค่าเดิม)"
"Delete thread" = "ลบเธรด"
"Delete variable" = "ลบตัวแปร"
@@ -777,8 +781,10 @@ status = "draft — needs native review"
"Main menu" = "เมนูหลัก"
"Make a component" = "สร้างคอมโพเนนต์"
"Make slot" = "สร้างสล็อต"
"New collection" = "คอลเลกชันใหม่"
"Pasted design layers" = "เลเยอร์ดีไซน์ที่วาง"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "วางเลเยอร์ดีไซน์แล้ว คลิปบอร์ดไม่มีภาพของ: {layers}"
"Remove these results from the file" = "ลบผลลัพธ์นี้ออกจากไฟล์"
"Scroll wheel zoom" = "ซูมด้วยล้อเลื่อนเมาส์"
"Slot name" = "ชื่อสล็อต"
"Mar" = "มี.ค."
@@ -915,6 +921,8 @@ status = "draft — needs native review"
"Not read yet" = "ยังไม่ได้อ่าน"
"Check for updates" = "ตรวจหาอัปเดต"
"Not checked yet" = "ยังไม่ได้ตรวจ"
"Someone who left" = "คนที่ออกไปแล้ว"
"Take the results off the board; they stay in the file" = "นำผลลัพธ์ออกจากบอร์ด โดยยังเก็บไว้ในไฟล์"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ไฟล์นี้ใช้ไลบรารีทีมบนเซิร์ฟเวอร์ที่คุณยังไม่เคยใช้ที่นี่ จะไม่มีการติดต่อเซิร์ฟเวอร์เหล่านั้นจนกว่าคุณจะตรวจ"
"Not shared" = "ไม่ได้แชร์"
"Note {n}" = "โน้ต {n}"
@@ -1757,6 +1765,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · อยู่บนเซิร์ฟเวอร์"
"{name} · published version {v}" = "{name} · เผยแพร่เวอร์ชัน {v}"
"{name} · version {v} on the server" = "{name} · เวอร์ชัน {v} บนเซิร์ฟเวอร์"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name} อยู่ในเซสชันสด"
"{name}, online, open only to you" = "{name} ออนไลน์ เปิดได้เฉพาะคุณ"
"{name}, {kind}, {w} by {h}, selected" = "{name} {kind} {w} คูณ {h} เลือกอยู่"
+9
View File
@@ -19,6 +19,7 @@ name = "Türkçe"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} satır (Δ {delta}), +{bin_added} / −{bin_deleted} ikili varlık, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Bu dosyanın bu modları ve değişkenleri içeren bir koleksiyonu: değerlerini mod başına geçersiz kılın ve kendi değişkenlerinizi ekleyin"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Bu adda bir .figcraft dosyası zaten var. Değiştirmeyi onaylamak için tam adını seçin."
"A later vote" = "Daha sonraki oylama"
"A responsive product page: hero, features, figures, a quote and a footer" = "Duyarlı bir ürün sayfası: giriş bölümü, özellikler, rakamlar, bir alıntı ve bir alt bilgi"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Yay, durulana kadar oynar: kütlesi, sertliği ve sönümü ne kadar süreceğini belirler"
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Bir tablo en fazla {rows} satır ve {columns} sütun alır: gerisi yapıştırılmadı"
@@ -108,6 +109,7 @@ name = "Türkçe"
"Alphabetical" = "Alfabetik"
"Alt+drag" = "Alt+sürükle"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Izgara üzerinde bir analiz ekranı: rakamlar, vektörlerden çizilmiş grafikler ve bir tablo"
"An earlier vote" = "Daha önceki oylama"
"An invite link that opens FigCraft and joins" = "FigCraft'ı açıp katılan bir davet bağlantısı"
"Anchor" = "Bağlantı noktası"
"Android" = "Android"
@@ -345,6 +347,7 @@ name = "Türkçe"
"Create component" = "Bileşen oluştur"
"Create component property" = "Bileşen özelliği oluştur"
"Create components, styles or variables to publish them as a library." = "Kitaplık olarak yayımlamak için bileşen, stil veya değişken oluşturun."
"Create from color" = "Renkten oluştur"
"Create property…" = "Özellik oluştur…"
"Create style" = "Stil oluştur"
"Create style from the selected text" = "Seçili metinden stil oluştur"
@@ -381,6 +384,7 @@ name = "Türkçe"
"Delete page" = "Sayfayı sil"
"Delete preset" = "Ön ayarı sil"
"Delete property" = "Özelliği sil"
"Delete results" = "Sonuçları sil"
"Delete the style (layers keep their values)" = "Stili sil (katmanlar değerlerini korur)"
"Delete thread" = "Konuyu sil"
"Delete variable" = "Değişkeni sil"
@@ -768,8 +772,10 @@ name = "Türkçe"
"Main menu" = "Ana menü"
"Make a component" = "Bileşen yap"
"Make slot" = "Yuva yap"
"New collection" = "Yeni koleksiyon"
"Pasted design layers" = "Yapıştırılan tasarım katmanları"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Tasarım katmanları yapıştırıldı; pano şunların görsellerini taşımıyordu: {layers}"
"Remove these results from the file" = "Bu sonuçları dosyadan kaldır"
"Scroll wheel zoom" = "Kaydırma tekerleğiyle yakınlaştırma"
"Slot name" = "Yuva adı"
"Mar" = "Mar"
@@ -905,6 +911,8 @@ name = "Türkçe"
"Not read yet" = "Henüz okunmadı"
"Check for updates" = "Güncellemeleri denetle"
"Not checked yet" = "Henüz denetlenmedi"
"Someone who left" = "Ayrılan biri"
"Take the results off the board; they stay in the file" = "Sonuçları panodan kaldır; dosyada kalır"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Bu dosya, burada kullanmadığınız sunuculardaki ekip kitaplıklarını kullanıyor. Siz denetleyene kadar bu sunuculara bağlanılmaz."
"Not shared" = "Paylaşılmadı"
"Note {n}" = "Not {n}"
@@ -1731,6 +1739,7 @@ name = "Türkçe"
"{name} · on a server" = "{name} · bir sunucuda"
"{name} · published version {v}" = "{name} · yayımlanan sürüm {v}"
"{name} · version {v} on the server" = "{name} · sunucuda sürüm {v}"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, canlı oturumda"
"{name}, online, open only to you" = "{name}, çevrimiçi, yalnızca size açık"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, seçili"
+9
View File
@@ -18,6 +18,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commit, +{added} / −{deleted} dòng (Δ {delta}), +{bin_added} / −{bin_deleted} tài nguyên nhị phân, {first} – {last}"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Đã tồn tại tệp .figcraft có tên này. Hãy chọn tên đầy đủ của tệp để xác nhận thay thế."
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Một bộ sưu tập của tệp này với các chế độ và biến sau: ghi đè giá trị theo từng chế độ và thêm các biến riêng"
"A later vote" = "Lần bình chọn sau"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Một lớp riêng lẻ sẽ căn chỉnh theo khung của nó: hãy chọn thêm lớp, hoặc chọn một lớp bên trong khung"
"A responsive product page: hero, features, figures, a quote and a footer" = "Trang sản phẩm đáp ứng: phần mở đầu, tính năng, số liệu, trích dẫn và chân trang"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Chuyển động lò xo chạy cho đến khi ổn định: khối lượng, độ cứng và độ giảm chấn quyết định thời lượng"
@@ -101,6 +102,7 @@ status = "draft — needs native review"
"Alphabetical" = "Theo bảng chữ cái"
"Alt+drag" = "Alt+kéo"
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Màn hình phân tích trên lưới: số liệu, biểu đồ vẽ bằng vector và bảng dữ liệu"
"An earlier vote" = "Lần bình chọn trước"
"An invite link that opens FigCraft and joins" = "Liên kết mời giúp mở FigCraft và tham gia ngay"
"Anchor" = "Điểm neo"
"Android" = "Android"
@@ -336,6 +338,7 @@ status = "draft — needs native review"
"Create component" = "Tạo thành phần"
"Create component property" = "Tạo thuộc tính thành phần"
"Create components, styles or variables to publish them as a library." = "Hãy tạo các thành phần, kiểu hoặc biến để xuất bản thành thư viện."
"Create from color" = "Tạo từ màu"
"Create property…" = "Tạo thuộc tính…"
"Create style" = "Tạo kiểu"
"Create style from the selected text" = "Tạo kiểu từ văn bản đã chọn"
@@ -372,6 +375,7 @@ status = "draft — needs native review"
"Delete page" = "Xóa trang"
"Delete preset" = "Xóa thiết lập sẵn"
"Delete property" = "Xóa thuộc tính"
"Delete results" = "Xóa kết quả"
"Delete the style (layers keep their values)" = "Xóa kiểu (các lớp vẫn giữ nguyên giá trị)"
"Delete thread" = "Xóa luồng bình luận"
"Delete variable" = "Xóa biến"
@@ -756,8 +760,10 @@ status = "draft — needs native review"
"Main menu" = "Trình đơn chính"
"Make a component" = "Tạo thành phần"
"Make slot" = "Tạo ô chứa"
"New collection" = "Bộ sưu tập mới"
"Pasted design layers" = "Các lớp thiết kế đã dán"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Đã dán các lớp thiết kế; bộ nhớ tạm không có hình ảnh của: {layers}"
"Remove these results from the file" = "Xóa kết quả này khỏi tệp"
"Scroll wheel zoom" = "Thu phóng bằng con lăn chuột"
"Slot name" = "Tên ô chứa"
"Mar" = "Thg 3"
@@ -891,6 +897,8 @@ status = "draft — needs native review"
"Not read yet" = "Chưa đọc"
"Check for updates" = "Kiểm tra cập nhật"
"Not checked yet" = "Chưa kiểm tra"
"Someone who left" = "Người đã rời đi"
"Take the results off the board; they stay in the file" = "Gỡ kết quả khỏi bảng; vẫn giữ trong tệp"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tệp này dùng thư viện nhóm trên các máy chủ bạn chưa dùng ở đây. Các máy chủ đó không được liên hệ cho đến khi bạn kiểm tra."
"Not shared" = "Chưa chia sẻ"
"Note {n}" = "Ghi chú {n}"
@@ -1708,6 +1716,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · trên máy chủ"
"{name} · published version {v}" = "{name} · đã xuất bản phiên bản {v}"
"{name} · version {v} on the server" = "{name} · phiên bản {v} trên máy chủ"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name}, trong phiên trực tiếp"
"{name}, online, open only to you" = "{name}, trực tuyến, chỉ mình bạn mở được"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, đã chọn"
+9
View File
@@ -20,6 +20,7 @@ status = "draft — needs native review"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:{prs} 个 PR,{commits} 次提交,+{added} / −{deleted} 行(Δ {delta}),二进制资源 +{bin_added} / −{bin_deleted},{first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "本文件中带有这些模式和变量的集合:可按模式覆盖其值,并添加自己的变量"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "已存在同名的 .figcraft 文件。请选择其完整名称以确认替换。"
"A later vote" = "较新的投票"
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "单个图层与其所在画框对齐:请再选择其他图层,或选择画框内的图层"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "弹簧动画播放至静止,时长由质量、刚度和阻尼决定"
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "表格最多 {rows} 行 {columns} 列:其余内容未粘贴"
@@ -105,6 +106,7 @@ status = "draft — needs native review"
"Along the top" = "沿顶边"
"Alpha" = "Alpha"
"Alt+drag" = "Alt+拖动"
"An earlier vote" = "较早的投票"
"An invite link that opens FigCraft and joins" = "可打开 FigCraft 并加入的邀请链接"
"Anchor" = "锚点"
"Android" = "Android"
@@ -342,6 +344,7 @@ status = "draft — needs native review"
"Create component" = "创建组件"
"Create component property" = "创建组件属性"
"Create components, styles or variables to publish them as a library." = "创建组件、样式或变量,即可将其发布为库。"
"Create from color" = "从颜色创建"
"Create property…" = "创建属性…"
"Create style" = "创建样式"
"Create style from the selected text" = "从所选文本创建样式"
@@ -374,6 +377,7 @@ status = "draft — needs native review"
"Delete page" = "删除页面"
"Delete preset" = "删除预设"
"Delete property" = "删除属性"
"Delete results" = "删除结果"
"Delete the style (layers keep their values)" = "删除样式(图层保留其值)"
"Delete thread" = "删除讨论"
"Delete variable" = "删除变量"
@@ -744,8 +748,10 @@ status = "draft — needs native review"
"Main component deleted" = "主组件已删除"
"Main menu" = "主菜单"
"Make slot" = "设为插槽"
"New collection" = "新建集合"
"Pasted design layers" = "粘贴的设计图层"
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "已粘贴设计图层;剪贴板未包含以下图层的图像:{layers}"
"Remove these results from the file" = "从文件中删除这些结果"
"Scroll wheel zoom" = "滚轮缩放"
"Slot name" = "插槽名称"
"Margin" = "边距"
@@ -876,6 +882,8 @@ status = "draft — needs native review"
"Not read yet" = "尚未读取"
"Check for updates" = "检查更新"
"Not checked yet" = "尚未检查"
"Someone who left" = "已离开的人"
"Take the results off the board; they stay in the file" = "从白板上移除结果;结果仍保留在文件中"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "此文件使用了你在此处未用过的服务器上的团队库。在你检查之前,不会连接这些服务器。"
"Not shared" = "未分享"
"Note {n}" = "备注 {n}"
@@ -1684,6 +1692,7 @@ status = "draft — needs native review"
"{name} · on a server" = "{name} · 在服务器上"
"{name} · published version {v}" = "{name} · 已发布版本 {v}"
"{name} · version {v} on the server" = "{name} · 服务器上的版本 {v}"
"{name} ×{n}" = "{name} ×{n}"
"{name}, in a live session" = "{name},正在实时会话中"
"{name}, online, open only to you" = "{name},在线,只有你能打开"
"{name}, {kind}, {w} by {h}, selected" = "{name},{kind},{w} × {h},已选中"
+1
View File
@@ -126,6 +126,7 @@ impl FigApp {
}
"dialog.preferences" => self.ui.preferences = true,
"dialog.missingFonts" => missing_fonts::request(self),
"dialog.createFromColor" => crate::sampled_color::request(self, &r.data),
"panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel,
"palette.actions" => menus::toggle_palette(&mut self.ui),
"panel.design" if self.session.doc().is_ok_and(figcraft_engine::board::is_board) => self.ui.board_panel = !self.ui.board_panel,
+116 -33
View File
@@ -4,7 +4,7 @@
use std::time::Duration;
use egui::{Align2, Color32, CornerRadius, Rect, RichText, Stroke, Ui, pos2, vec2};
use figcraft_doc::{BoardTimer, NodeId};
use figcraft_doc::BoardTimer;
use figcraft_engine::cmd::facilitate::{self, BADGE_PX};
use serde_json::{Value, json};
@@ -20,22 +20,22 @@ 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).
/// What the pill shows of the vote: none running (with Results when any are kept or on the
/// board), or running (your votes left).
enum VoteView {
Off,
Off { results: bool },
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 Ok(doc) = app.session.doc() else { return VoteView::Off { results: false } };
match &doc.vote {
Some(vote) if !vote.ended => {
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) }
}
ended => VoteView::Off { results: ended.is_some() || !doc.vote_history.is_empty() },
}
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.
@@ -152,11 +152,20 @@ fn timer_popup(app: &mut FigApp, anchor: &egui::Response) {
fn vote_part(app: &mut FigApp, ui: &mut Ui, vote: VoteView, editable: bool, t: &Tokens) {
match vote {
VoteView::Off => {
VoteView::Off { results } => {
let resp = icons::button(ui, Icon::Vote, CELL, false, tr("Voting session"));
if editable {
vote_popup(app, &resp);
}
// Earlier votes' results stay in the file until deleted.
if results {
let resp = widgets::button(ui, tr("Results"), false, true);
if resp.clicked() {
// Opening shows the latest vote's results.
ui.data_mut(|d| d.remove::<usize>(egui::Id::unique("board_vote_session")));
}
results_popup(app, &resp, editable);
}
}
VoteView::Running { left } => {
let (r, resp) = ui.allocate_exact_size(vec2(20.0, CELL), egui::Sense::hover());
@@ -169,12 +178,6 @@ fn vote_part(app: &mut FigApp, ui: &mut Ui, vote: VoteView, editable: bool, t: &
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);
}
}
}
@@ -213,26 +216,106 @@ fn vote_popup(app: &mut FigApp, anchor: &egui::Response) {
});
}
/// 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| {
/// Ended votes' results, newest first with arrows to earlier ones: each object, most votes first,
/// with who voted for it (a click on a row selects the object). Clear takes the results on the
/// board off it; Delete removes the results shown from the file.
fn results_popup(app: &mut FigApp, anchor: &egui::Response, editable: bool) {
// It stays open while you step through votes, pick objects or delete results.
crate::right::menu_popup(anchor).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
// Built only while it shows.
let sessions = facilitate::sessions(&app.session).unwrap_or_default();
let pick_id = egui::Id::unique("board_vote_session");
let pick = ui.data(|d| d.get_temp::<usize>(pick_id)).unwrap_or(0).min(sessions.len().saturating_sub(1));
let secondary = Tokens::get(ui.ctx()).text_secondary;
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);
if let Some(p) = results_header(ui, pick, sessions.len(), secondary) {
ui.data_mut(|d| d.insert_temp(pick_id, p));
}
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 sessions.get(pick).is_none_or(|s| s.rows.is_empty()) {
widgets::label(ui, tr("No votes yet"), secondary);
}
let Some(session) = sessions.get(pick) else { return };
for row in &session.rows {
result_row(app, ui, row, secondary);
}
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();
results_footer(app, ui, session);
}
});
}
/// The popover's title and, with several votes, the "‹ n of m ›" stepper. Returns the vote an
/// arrow stepped to.
fn results_header(ui: &mut Ui, pick: usize, count: usize, secondary: Color32) -> Option<usize> {
let mut step = None;
ui.horizontal(|ui| {
ui.label(RichText::new(tr("Results")).font(semibold(12.0)));
if count > 1 {
ui.add_space(8.0);
// Newest first: "1 of 3" is the last vote, the right arrow goes further back. Both
// arrows always show (one dimmed at an end), so the count stays put.
if ui.add_enabled_ui(pick > 0, |ui| widgets::icon_button(ui, Icon::ChevronLeft, tr("A later vote"))).inner {
step = pick.checked_sub(1);
}
let at = trf("{n} of {total}", &[("n", &(pick + 1)), ("total", &count)]);
ui.label(RichText::new(at).font(regular(11.0)).color(secondary));
if ui.add_enabled_ui(pick + 1 < count, |ui| widgets::icon_button(ui, Icon::ChevronRight, tr("An earlier vote"))).inner {
step = Some(pick + 1).filter(|p| *p < count);
}
}
});
step
}
/// One object in the results (a click selects it), with who voted for it under it.
fn result_row(app: &mut FigApp, ui: &mut Ui, row: &facilitate::ResultRow, secondary: Color32) {
let label = format!("{}. {}", row.rank, widgets::elide(ui, &row.name, 160.0));
if crate::right::menu_item(ui, &label, &trn("{n} votes", row.votes as usize, &[])) {
app.run("select.set", json!({"ids": [row.id]}));
}
let full = voters_line(row);
let shown = widgets::elide(ui, &full, 236.0);
// Close under its row, apart from the next one.
ui.add_space(-8.0);
let resp = ui.horizontal(|ui| {
ui.add_space(8.0);
widgets::label(ui, &shown, secondary);
});
ui.add_space(6.0);
if shown != full {
resp.response.on_hover_text(full);
}
}
/// "Ada, Grace ×2": a person with several votes on the object says how many.
fn voters_line(row: &facilitate::ResultRow) -> String {
let who: Vec<String> = row
.voters
.iter()
.map(|(_, name, n)| {
let name = name.clone().unwrap_or_else(|| tr("Someone who left").to_string());
if *n > 1 { trf("{name} ×{n}", &[("name", &name), ("n", &n.to_string())]) } else { name }
})
.collect();
who.join(", ")
}
/// Clear (while the results are on the board) and Delete (for kept results).
fn results_footer(app: &mut FigApp, ui: &mut Ui, session: &facilitate::VoteSession) {
ui.add_space(4.0);
ui.horizontal(|ui| {
if session.on_board
&& widgets::button(ui, tr("Clear results"), false, true)
.on_hover_text(tr("Take the results off the board; they stay in the file"))
.clicked()
{
app.run("board.vote", json!({"action": "clear"}));
ui.close();
}
if let Some(index) = session.index
&& widgets::button(ui, tr("Delete results"), false, true).on_hover_text(tr("Remove these results from the file")).clicked()
{
app.run("board.vote", json!({"action": "delete", "index": index}));
}
});
}
+29 -2
View File
@@ -48,8 +48,8 @@ pub struct ControlReply {
/// Which side has finished a control request.
///
/// The transport and the UI race to settle one request: while it is `Open`, either may cancel it
/// (the transport's timeout) or commit it (the UI's final reply). Once settled, neither side may
/// change it.
/// (the transport's timeout) or commit it (the UI's final reply) or, for a request the UI answers
/// at once, start it (`Running`). Once settled or started, the transport can't cancel it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RequestState {
#[default]
@@ -58,6 +58,9 @@ pub enum RequestState {
Cancelled,
/// The UI wrote the file (if any) and queued the reply before releasing the gate.
Replied,
/// The UI started a request it answers at once (an edit, an inspection); the transport can no
/// longer cancel it, so a timeout now means "still running, it will take effect" (#1673).
Running,
}
/// The gate the transport's timeout and the UI's final reply share for one request.
@@ -102,6 +105,17 @@ impl RequestGate {
*state
}
/// Start a request the UI answers at once, unless the transport already gave up on it: then
/// it must not run at all. `false` when it was cancelled (or already settled).
pub fn start(&self) -> bool {
let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if *state != RequestState::Open {
return false;
}
*state = RequestState::Running;
true
}
/// Run the final commit (file write and reply enqueue) only while the request is still open,
/// then mark it replied before releasing the gate. `None` means the transport cancelled it or
/// it was already committed; the closure must not have run then.
@@ -131,6 +145,13 @@ impl ControlRequest {
pub fn is_cancelled(&self) -> bool {
self.state.is_cancelled()
}
/// Mark the request started just before the UI runs it; `false` when the transport timed out
/// first, and then it must not run. `ui.input` is acknowledged by a later frame, so it stays
/// open (cancellable) like a screenshot.
fn begin(&self) -> bool {
self.method == "ui.input" || self.state.start()
}
}
/// One screenshot's completion slot.
@@ -519,6 +540,9 @@ pub fn background(app: &mut FigApp, ctx: &Context) {
let _ = r.reply.send(ControlReply { id: r.id, result: Err(error) });
continue;
}
if !r.begin() {
continue;
}
// Outside a frame, so outside the frame's guard: within the window's swap, a panic still
// ends in recovery copies and leaves every window its own state.
let res = crate::windows::as_window(app, target, |app| {
@@ -719,6 +743,9 @@ fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now
admit(app, r, target, now);
return;
}
if !r.begin() {
return;
}
// Every control-channel gesture is fenced from the user's system clipboard (the flag is
// restored so a batch already in a frame's raw input keeps its own provenance).
let before = app.control_synthetic;
+66
View File
@@ -0,0 +1,66 @@
//! The stroke settings' Dash and Gap fields edit the first pair of a dash pattern and keep the
//! rest of it (#1681): an imported `[4, 2, 1, 2]` used to collapse to `[dash, gap]` on the first edit.
use egui::{Event, Id, Key, Modifiers};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
/// A selected rectangle with a stroke dashed `dashes`, its advanced stroke settings shown.
fn scene(dashes: serde_json::Value) -> (Ui, NodeId) {
let mut s = Session::new();
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"].as_u64().unwrap());
s.execute("node.set", &json!({"ids": [r], "props": {"strokes": ["#000000"], "strokeWeight": 2, "dashes": dashes}})).unwrap();
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
u.ctx.data_mut(|d| d.insert_temp(Id::unique("stroke_adv"), true));
u.frame(vec![]);
(u, r)
}
fn dashes(u: &Ui, r: NodeId) -> Vec<f64> {
u.app.session.doc().unwrap().node(r).unwrap().stroke.dashes.clone()
}
/// Type `text` into the number field `id` and press Enter.
fn type_into(u: &mut Ui, id: &str, text: &str) {
let at = u.ctx.read_response(Id::unique(id).with("field")).unwrap_or_else(|| panic!("{id} shows")).rect.center();
u.button(at, true);
u.button(at, false);
u.frame(vec![]);
u.frame(vec![Event::Text(text.into())]);
u.frame(vec![
Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
]);
u.frame(vec![]);
}
#[test]
fn editing_the_dash_or_gap_keeps_the_rest_of_the_pattern() {
let (mut u, r) = scene(json!([4, 2, 1, 2]));
type_into(&mut u, "stroke_dash", "6");
assert_eq!(dashes(&u, r), [6.0, 2.0, 1.0, 2.0]);
type_into(&mut u, "stroke_gap", "3");
assert_eq!(dashes(&u, r), [6.0, 3.0, 1.0, 2.0]);
}
#[test]
fn a_single_value_pattern_is_the_dash_and_the_gap() {
let (mut u, r) = scene(json!([5]));
type_into(&mut u, "stroke_gap", "2");
assert_eq!(dashes(&u, r), [5.0, 2.0]);
}
/// An odd-length pattern draws twice over (`[4, 2, 1]` is `[4, 2, 1, 4, 2, 1]`): editing its Dash
/// writes the repeat out, so only the first dash changes and every gap stays as drawn.
#[test]
fn an_odd_length_pattern_keeps_its_repeat() {
let (mut u, r) = scene(json!([4, 2, 1]));
type_into(&mut u, "stroke_dash", "6");
assert_eq!(dashes(&u, r), [6.0, 2.0, 1.0, 4.0, 2.0, 1.0]);
type_into(&mut u, "stroke_gap", "3");
assert_eq!(dashes(&u, r), [6.0, 3.0, 1.0, 4.0, 2.0, 1.0]);
}
+2
View File
@@ -34,6 +34,7 @@ pub mod credits;
pub mod import_report;
pub mod missing_fonts;
pub mod plugins_ui;
pub mod sampled_color;
pub mod updates_ui;
pub mod variables_panel;
@@ -209,6 +210,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
crate::pending_open::pending_opens(app, ctx);
crate::import_report::show(app, ctx);
crate::missing_fonts::show(app, ctx);
crate::sampled_color::show(app, ctx);
let t = Tokens::get(ctx);
if app.ui.about {
let mut open = true;
+136
View File
@@ -0,0 +1,136 @@
//! A colour picked with the eyedropper while holding Cmd/Ctrl+Shift becomes a colour style or a
//! colour variable, as in the reference app: the eyedropper asks for this window
//! (`dialog.createFromColor`), which asks which to make, what to call it and (a variable) in which
//! collection, then runs `style.create` or `variable.create`.
use egui::{Context, RichText};
use figcraft_color::{Color, Paint};
use serde_json::{Value, json};
use super::window;
use crate::FigApp;
use crate::i18n::tr;
use crate::theme::Tokens;
use crate::widgets;
/// The window's state: the colour, what to make of it and its name.
#[derive(Clone, Debug, PartialEq)]
pub struct SampledColor {
/// The document it was picked in: the window goes when another is in front.
pub uid: u64,
/// `#rrggbb`.
pub color: String,
/// A variable (else a style).
pub variable: bool,
pub name: String,
/// The local collection a variable goes in (`None`: a new one).
pub collection: Option<String>,
}
/// `dialog.createFromColor {color}` from the eyedropper: the window for the document in front, with
/// the next free style name ("Color 1"…) and its first collection.
pub fn request(app: &mut FigApp, data: &Value) {
let Some(color) = data.get("color").and_then(Value::as_str).and_then(Color::from_hex) else { return };
let Ok(ds) = app.session.ds() else { return };
let collection = figcraft_engine::library::local_collections(&ds.doc).next().map(|c| c.id.clone());
app.ui.sampled_color = Some(SampledColor {
uid: ds.uid,
color: color.to_css_hex(),
variable: false,
name: crate::style_picker::next_name(&ds.doc, "Color"),
collection,
});
}
/// The command that makes what `s` asks for: a paint style, a colour variable in its collection,
/// or (no collection) a new collection and the variable in it, one undo step.
pub fn create_params(app: &FigApp, s: &SampledColor) -> (&'static str, Value) {
let name = s.name.trim();
if !s.variable {
return ("style.create", json!({"type": "paint", "name": name, "value": s.color}));
}
let variable = |collection: Value| json!({"collection": collection, "name": name, "type": "color", "value": s.color});
match &s.collection {
Some(c) => ("variable.create", variable(json!(c))),
None => {
let taken = |n: &str| app.session.doc().is_ok_and(|d| d.variables.collections.iter().any(|c| c.name == n));
let collection = crate::variables_panel::unique("Collection", taken);
let new = json!({"command": "variable.createCollection", "params": {"name": collection, "modes": ["Mode 1"]}, "as": "collection"});
let add = json!({"command": "variable.create", "params": variable(json!({"$ref": "collection"}))});
("command.batch", json!({"label": "Create variable", "commands": [new, add]}))
}
}
}
/// A variable's collection: one of the file's, or a new one.
fn collection_picker(ui: &mut egui::Ui, s: &mut SampledColor, collections: &[(String, String)]) {
let shown = s.collection.as_ref().and_then(|id| collections.iter().find(|c| &c.0 == id)).map_or(tr("New collection"), |c| c.1.as_str());
let resp = widgets::dropdown(ui, shown, 260.0, None);
crate::right::menu_popup(&resp).show(|ui| {
for (id, label) in collections {
if crate::right::menu_item(ui, label, "") {
s.collection = Some(id.clone());
}
}
if crate::right::menu_item(ui, tr("New collection"), "") {
s.collection = None;
}
});
}
/// The window: a swatch of the colour, Style or Variable, its name, a variable's collection,
/// Cancel and Create (Enter creates).
pub fn show(app: &mut FigApp, ctx: &Context) {
let Some(mut s) = app.ui.sampled_color.clone() else { return };
if app.session.ds().map(|d| d.uid).ok() != Some(s.uid) {
app.ui.sampled_color = None;
return;
}
let t = Tokens::get(ctx);
let collections: Vec<(String, String)> = app
.session
.doc()
.map(|d| figcraft_engine::library::local_collections(d).map(|c| (c.id.clone(), c.name.clone())).collect())
.unwrap_or_default();
let (mut open, mut cancel, mut create) = (true, false, false);
window(tr("Create from color"), ctx).id(egui::Id::unique("Create from color")).open(&mut open).show(ctx, |ui| {
ui.set_width(280.0);
ui.horizontal(|ui| {
let paint = Color::from_hex(&s.color).map(Paint::solid);
if let Some(p) = &paint {
widgets::swatch(ui, p, 24.0);
}
ui.label(RichText::new(s.color.trim_start_matches('#').to_uppercase()).color(t.text));
});
ui.add_space(8.0);
if widgets::radio(ui, !s.variable, tr("Style")) {
s.variable = false;
}
if widgets::radio(ui, s.variable, tr("Variable")) {
s.variable = true;
}
ui.add_space(6.0);
let name = ui.add(egui::TextEdit::singleline(&mut s.name).hint_text(tr("Name")).desired_width(260.0));
widgets::select_all_on_focus(ui, &name, &s.name);
if ui.memory(|m| m.focused().is_none()) {
name.request_focus();
}
if s.variable {
ui.add_space(6.0);
collection_picker(ui, &mut s, &collections);
}
ui.add_space(10.0);
let ready = !s.name.trim().is_empty();
ui.horizontal(|ui| {
cancel = widgets::button(ui, tr("Cancel"), false, true).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape));
let enter = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
create = (widgets::button(ui, tr("Create"), true, ready).clicked() || enter) && ready;
});
});
let made = create && {
let (cmd, params) = create_params(app, &s);
app.run(cmd, params).is_some()
};
// A name already taken leaves the window open to change it (the error says why).
app.ui.sampled_color = (open && !cancel && !made).then_some(s);
}
@@ -281,7 +281,7 @@ fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect
}
/// The first of `base`, `base 2`, `base 3`… that `taken` doesn't hold.
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
pub(crate) fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
(1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
+2 -2
View File
@@ -95,7 +95,7 @@ fn export_rows_offer_a_size_for_images_only() {
}
/// The text painted this frame, with the centre of each piece.
fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> {
pub(crate) fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> {
fn walk(shape: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) {
match shape {
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.rect.center().to_vec2())),
@@ -112,7 +112,7 @@ fn labelled(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, egui::Pos2)> {
out
}
fn click(u: &mut crate::layers_drag_tests::Ui, p: egui::Pos2) {
pub(crate) fn click(u: &mut crate::layers_drag_tests::Ui, p: egui::Pos2) {
u.frame(vec![egui::Event::PointerMoved(p)]);
u.button(p, true);
u.button(p, false);
+41
View File
@@ -344,6 +344,47 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() {
assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL);
}
/// #1673: the UI runs a queued request only if the transport hasn't given up on it, and marks it
/// started first, so a timeout can tell "not applied" from "still running". Both from a frame and
/// from a hidden window's logic pass.
#[test]
fn a_request_the_transport_gave_up_on_is_never_applied() {
let (mut h, text) = document();
h.settle();
let (tx, rx) = std::sync::mpsc::channel();
let rename = |id: u64, name: &str| ControlRequest {
id: json!(id),
method: "engine.execute".into(),
params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}),
reply: tx.clone(),
state: Default::default(),
};
let node = figcraft_doc::NodeId(text.as_u64().unwrap());
let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone();
let before = name(&h);
let mut hidden = RawInput::default();
hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() });
for on_screen in [true, false] {
let gave_up = rename(1, "late");
gave_up.state.cancel_if_open();
let wanted = rename(2, "wanted");
let gate = wanted.state.clone();
h.app.control_queue.extend([gave_up, wanted]);
if on_screen {
h.frame(vec![]);
} else {
let ctx = h.ctx.clone();
let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx));
}
let reply = rx.try_recv().expect("the wanted request is answered");
assert_eq!(reply.id, json!(2), "on screen: {on_screen}");
assert!(rx.try_recv().is_err(), "nothing answers the cancelled one");
assert_eq!(gate.state(), crate::RequestState::Running, "started before it ran");
assert_eq!(name(&h), "wanted");
h.app.session.execute("node.set", &json!({"ids": [text.clone()], "props": {"name": before.clone()}})).unwrap();
}
}
/// Asserts a hidden window answers edits and inspection at once, while a screenshot is
/// parked with a deadline and times out once instead of waiting for a frame.
#[test]
+12
View File
@@ -90,6 +90,8 @@ mod crop_image_tests;
#[cfg(test)]
mod cursors_tests;
#[cfg(test)]
mod dash_pattern_tests;
#[cfg(test)]
mod design_paste_tests;
#[cfg(test)]
mod dev_mode_tests;
@@ -221,6 +223,8 @@ mod proto_responsive_tests;
#[cfg(test)]
mod resources_tests;
#[cfg(test)]
mod sampled_color_tests;
#[cfg(test)]
mod save_as_tests;
#[cfg(test)]
mod select_all_tests;
@@ -257,6 +261,10 @@ mod vector_panel_tests;
#[cfg(test)]
mod vector_tail_ui_tests;
#[cfg(test)]
mod vote_history_ui_tests;
#[cfg(test)]
mod vote_voters_tests;
#[cfg(test)]
mod web_clipboard_tests;
#[cfg(test)]
mod web_save_tests;
@@ -284,6 +292,7 @@ pub use dialogs::credits;
pub use dialogs::import_report;
pub use dialogs::missing_fonts;
pub use dialogs::plugins_ui;
pub use dialogs::sampled_color;
pub use dialogs::updates_ui;
pub use dialogs::variables_panel;
pub use home::home_recents;
@@ -498,6 +507,8 @@ pub struct UiState {
/// Files the user opened whose fonts are yet to be checked (by document uid).
pub font_checks: Vec<u64>,
pub missing_fonts: Option<missing_fonts::Shown>,
/// The eyedropper's Cmd/Ctrl+Shift pick, to make a colour style or variable of.
pub sampled_color: Option<sampled_color::SampledColor>,
pub screenshot_requests: Vec<(u64, String)>,
pub frame_ms: f64,
pub render_ms: f64,
@@ -665,6 +676,7 @@ impl Default for UiState {
import_report: None,
font_checks: Vec::new(),
missing_fonts: None,
sampled_color: None,
screenshot_requests: Vec::new(),
frame_ms: 0.0,
render_ms: 0.0,
+18 -2
View File
@@ -187,6 +187,22 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option<Option
});
}
/// Dash pattern `dashes` with its first dash (`i` 0) or first gap (`i` 1) set to `v`, keeping the
/// rest of the pattern: the panel shows only the first pair, and editing it must not collapse an
/// imported `[4, 2, 1, 2]` to two values (#1681). An odd-length pattern is drawn repeated
/// (`figcraft_pathops::width::dash_pattern`: `[4, 2, 1]` is `[4, 2, 1, 4, 2, 1]`), so it is
/// written out that way first and only the edited entry changes; `[5]` becomes `[5, 5]`.
fn set_dash(dashes: &[f64], i: usize, v: f64) -> Vec<f64> {
let mut d = if dashes.is_empty() { vec![4.0] } else { dashes.to_vec() };
if d.len() % 2 == 1 {
d = d.repeat(2);
}
if let Some(x) = d.get_mut(i) {
*x = v.max(0.0);
}
d
}
/// Advanced stroke settings: dash pattern, width profile, join and the end points of open paths.
pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
@@ -214,9 +230,9 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel
widgets::sublabels(ui, tr("Dash"), Some(tr("Gap")));
row(ui, 32.0, |ui| {
let e = widgets::number_field(ui, Id::unique("stroke_dash"), Lead::None, Some(dash), "", half, 1.0);
app.field(&ids, "dashes", e, |v| json!([v.max(0.0), gap]));
app.field(&ids, "dashes", e, |v| json!(set_dash(&d, 0, v)));
let e = widgets::number_field(ui, Id::unique("stroke_gap"), Lead::None, Some(gap), "", half, 1.0);
app.field(&ids, "dashes", e, |v| json!([dash, v.max(0.0)]));
app.field(&ids, "dashes", e, |v| json!(set_dash(&d, 1, v)));
});
}
width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned()));
+70
View File
@@ -0,0 +1,70 @@
//! The window that makes a colour the eyedropper picked (with Cmd/Ctrl+Shift) into a colour style
//! or variable: it opens from the eyedropper's request, Create makes what it says, and a variable
//! goes into the chosen collection or, with none, a new one, in one undo step.
use serde_json::json;
use crate::export_preview_tests::{click, labelled};
use crate::layers_drag_tests::Ui;
use crate::sampled_color::{SampledColor, create_params};
fn opened(s: figcraft_engine::Session) -> Ui {
let mut u = Ui::new(s);
u.app.session.request_ui("dialog.createFromColor", json!({"color": "#ff8800"}));
u.app.handle_ui_requests();
u.frame(vec![]);
u.frame(vec![]);
u
}
fn press(u: &mut Ui, text: &str) {
let shown = labelled(u);
let at = shown.iter().find(|(t, _)| t == text).map(|x| x.1).unwrap_or_else(|| panic!("{text:?} in {shown:?}"));
click(u, at);
}
#[test]
fn the_eyedroppers_request_opens_the_window_and_create_makes_a_style() {
let mut u = opened(figcraft_engine::Session::new());
let s = u.app.ui.sampled_color.clone().expect("the window is open");
assert_eq!((s.color.as_str(), s.variable, s.name.as_str()), ("#FF8800", false, "Color 1"));
let texts: Vec<String> = labelled(&mut u).into_iter().map(|(t, _)| t).collect();
assert!(texts.iter().any(|t| t == "Create from color") && texts.iter().any(|t| t == "FF8800"), "{texts:?}");
press(&mut u, "Create");
let doc = u.app.session.doc().unwrap();
let style = doc.styles.iter().find(|s| s.name == "Color 1").expect("a style");
let colour = match &style.kind {
figcraft_doc::StyleKind::Paint { paints } => paints.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()),
_ => None,
};
assert_eq!(colour.as_deref(), Some("FF8800"), "a paint style of the colour: {style:?}");
assert!(u.app.ui.sampled_color.is_none(), "it closes");
}
#[test]
fn a_variable_goes_into_a_new_or_the_chosen_collection() {
// No collection yet: one is made with it, one undo step.
let mut u = opened(figcraft_engine::Session::new());
let undo = u.app.session.undo_depth().0;
if let Some(s) = u.app.ui.sampled_color.as_mut() {
s.variable = true;
s.name = "Accent".into();
}
u.frame(vec![]);
press(&mut u, "Create");
let doc = u.app.session.doc().unwrap();
let v = doc.variables.variables.iter().find(|v| v.name == "Accent").expect("a variable");
let c = doc.variables.collection(&v.collection).expect("its collection");
assert_eq!(c.name, "Collection");
assert_eq!(u.app.session.undo_depth().0, undo + 1, "one step");
// With collections: the first is chosen, and the variable goes there.
let mut s = figcraft_engine::Session::new();
s.execute("variable.createCollection", &json!({"name": "Brand"})).unwrap();
let u = opened(s);
let state = u.app.ui.sampled_color.clone().unwrap();
let brand = u.app.session.doc().unwrap().variables.collections[0].id.clone();
assert_eq!(state.collection.as_deref(), Some(brand.as_str()));
let (cmd, params) = create_params(&u.app, &SampledColor { variable: true, name: "Accent".into(), ..state });
assert_eq!((cmd, params["collection"].as_str()), ("variable.create", Some(brand.as_str())));
assert_eq!((params["type"].as_str(), params["value"].as_str()), (Some("color"), Some("#FF8800")));
}
@@ -0,0 +1,95 @@
//! The Results popover steps through the votes kept in the file, newest first, and deletes the one
//! it shows; Results stays on the pill while any are kept, after they're cleared off the board.
use figcraft_engine::Session;
use serde_json::json;
use crate::export_preview_tests::{click, labelled};
use crate::layers_drag_tests::Ui;
/// A board where "Ship it" won a first vote and "Wait" a second one, both kept, then cleared off
/// the board.
fn two_votes() -> Ui {
let mut s = Session::new();
s.execute("file.newBoard", &json!({})).unwrap();
let sticky =
|s: &mut Session, x: i32, text: &str| crate::present_tests::id(&s.execute("board.sticky", &json!({"x": x, "y": 0, "text": text})).unwrap());
let (a, b) = (sticky(&mut s, 0, "Ship it"), sticky(&mut s, 300, "Wait"));
for on in [a, b] {
s.execute("board.vote", &json!({"action": "start"})).unwrap();
s.execute("board.vote", &json!({"action": "cast", "id": on, "voter": "Ada"})).unwrap();
s.execute("board.vote", &json!({"action": "end"})).unwrap();
}
s.execute("board.vote", &json!({"action": "clear"})).unwrap();
Ui::new(s)
}
fn texts(u: &mut Ui) -> Vec<String> {
labelled(u).into_iter().map(|(t, _)| t).collect()
}
fn at(u: &mut Ui, text: &str) -> egui::Pos2 {
let shown = labelled(u);
shown.iter().find(|(t, _)| t == text).map(|x| x.1).unwrap_or_else(|| panic!("{text:?} in {shown:?}"))
}
/// Where the text `text` is drawn.
fn rect_of(u: &mut Ui, text: &str) -> egui::Rect {
fn walk(shape: &egui::Shape, text: &str) -> Option<egui::Rect> {
match shape {
egui::Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2())),
egui::Shape::Vec(v) => v.iter().find_map(|s| walk(s, text)),
_ => None,
}
}
u.frame(vec![]);
let frame = u.frame_output(vec![]);
frame.shapes.iter().find_map(|c| walk(&c.shape, text)).unwrap_or_else(|| panic!("no {text:?}"))
}
/// Clicks the stepper's left (later vote) or right (earlier vote) arrow, either side of "n of m".
fn step(u: &mut Ui, now: &str, right: bool) {
let r = rect_of(u, now);
// The 24-pixel arrows sit next to the count.
let x = if right { r.max.x + 14.0 } else { r.min.x - 14.0 };
click(u, egui::pos2(x, r.center().y));
}
fn has(u: &mut Ui, text: &str) -> bool {
texts(u).iter().any(|t| t == text)
}
#[test]
fn the_results_step_through_kept_votes_and_delete_one() {
let mut u = two_votes();
let results = at(&mut u, "Results");
click(&mut u, results);
let shown = texts(&mut u);
assert!(shown.iter().any(|t| t == "1 of 2"), "{shown:?}");
assert!(shown.iter().any(|t| t == "1. Wait"), "the latest first: {shown:?}");
assert!(!shown.iter().any(|t| t == "Clear results"), "nothing on the board to clear");
// At either end that arrow is dimmed and does nothing; the other steps.
step(&mut u, "1 of 2", false);
assert!(has(&mut u, "1 of 2"), "no later vote than the latest");
step(&mut u, "1 of 2", true);
assert!(has(&mut u, "2 of 2") && has(&mut u, "1. Ship it"), "the earlier vote");
step(&mut u, "2 of 2", true);
assert!(has(&mut u, "2 of 2"), "no earlier vote than the first");
step(&mut u, "2 of 2", false);
assert!(has(&mut u, "1 of 2") && has(&mut u, "1. Wait"), "back to the latest");
step(&mut u, "1 of 2", true);
// A row selects its object.
let row = at(&mut u, "1. Ship it");
click(&mut u, row);
let earlier = figcraft_engine::cmd::facilitate::sessions(&u.app.session).unwrap()[1].rows[0].id;
assert_eq!(u.app.session.selection(), [earlier]);
let delete = at(&mut u, "Delete results");
click(&mut u, delete);
assert_eq!(u.app.session.doc().unwrap().vote_history.len(), 1, "the earlier vote is deleted");
// It stays open, on what's left.
let shown = texts(&mut u);
assert!(shown.iter().any(|t| t == "1. Wait") && !shown.iter().any(|t| t == "1 of 2"), "one left: {shown:?}");
let delete = at(&mut u, "Delete results");
click(&mut u, delete);
assert!(u.app.session.doc().unwrap().vote_history.is_empty() && !has(&mut u, "Results"), "none left, and no Results to open");
}
+45
View File
@@ -0,0 +1,45 @@
//! An ended vote's results say who voted for each object (as the reference board app shows how
//! each participant voted once a vote ends); while it runs, nobody's votes but your own show.
use figcraft_engine::Session;
use serde_json::json;
use crate::export_preview_tests::{click, labelled};
use crate::layers_drag_tests::Ui;
/// A board with two stickies voted on by Ada (twice on the first) and Bo (once on each).
fn voted() -> Ui {
let mut s = Session::new();
s.execute("file.newBoard", &json!({})).unwrap();
let sticky =
|s: &mut Session, x: i32, text: &str| crate::present_tests::id(&s.execute("board.sticky", &json!({"x": x, "y": 0, "text": text})).unwrap());
let (a, b) = (sticky(&mut s, 0, "Ship it"), sticky(&mut s, 300, "Wait"));
s.execute("board.vote", &json!({"action": "start", "votes": 2})).unwrap();
for (who, on) in [("Ada", a), ("Ada", a), ("Bo", a), ("Bo", b)] {
s.execute("board.vote", &json!({"action": "cast", "id": on, "voter": who})).unwrap();
}
Ui::new(s)
}
#[test]
fn the_results_say_who_voted_for_each_object() {
let mut u = voted();
let running = labelled(&mut u);
assert!(!running.iter().any(|(t, _)| t.contains("Ada")), "nobody's names while it runs");
u.app.run("board.vote", json!({"action": "end"}));
let shown = labelled(&mut u);
let results = shown.iter().find(|(t, _)| t == "Results").map(|r| r.1).unwrap_or_else(|| panic!("a Results button in {shown:?}"));
click(&mut u, results);
let texts: Vec<String> = labelled(&mut u).into_iter().map(|(t, _)| t).collect();
let row = |name: &str| texts.iter().position(|t| t.ends_with(name)).unwrap_or_else(|| panic!("a row for {name} in {texts:?}"));
let (first, second) = (row("Ship it"), row("Wait"));
assert!(first < second, "most votes first");
assert!(texts[first..second].iter().any(|t| t == "Ada ×2, Bo"), "under the first: {texts:?}");
assert!(texts[second..].iter().any(|t| t == "Bo"), "under the second: {texts:?}");
// Clear takes the results off the board and keeps them in the file.
let shown = labelled(&mut u);
let clear = shown.iter().find(|(t, _)| t == "Clear results").map(|r| r.1).unwrap_or_else(|| panic!("Clear results in {shown:?}"));
click(&mut u, clear);
let doc = u.app.session.doc().unwrap();
assert!(doc.vote.is_none() && doc.vote_history.len() == 1, "cleared off the board, kept in the file");
}
+1 -1
View File
@@ -333,7 +333,7 @@ fn leaf(name: &str) -> &str {
}
/// `base N` with the smallest N no style of the document is named.
fn next_name(doc: &Document, base: &str) -> String {
pub(crate) fn next_name(doc: &Document, base: &str) -> String {
(1..).map(|n| format!("{base} {n}")).find(|n| doc.styles.iter().all(|s| &s.name != n)).unwrap_or_else(|| base.to_string())
}
+4 -2
View File
@@ -1,6 +1,6 @@
# Boards parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); 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)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); 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 ~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
@@ -49,7 +49,7 @@ all end with the same geometry.
| 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 |
| Voting results: who voted for what, earlier sessions' results | partial | once a vote ends, its results list who voted for each object and how many times (by name in a live session, kept with the results so someone who has left still shows by name), as the reference app shows how each participant voted; each ended vote's results are kept in the file (the last 20), listed newest first in the Results popover with arrows to earlier ones, and deleted one at a time (`board.vote {action: delete, index}`; Clear results only takes them off the board), as the reference app keeps each session's results until deleted; saved, synced in live sessions, and like the vote not undo steps. Anyone who can edit the board ends or clears a vote, as the reference app's help describes. A vote has no prompt (question) of its own |
| Board widgets | missing | design-file widgets exist (`crates/engine/src/extensions/widgets.rs`), not wired to boards |
| 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?}`; cells copied from a spreadsheet (tab-separated text, quoted cells kept whole) paste as a table, up to 50 × 20 with a note when there was more; undoable and synced in live sessions. No column resizing by dragging, no cell colours, no Tab between cells; comma-separated text pastes as text |
@@ -64,6 +64,8 @@ all end with the same geometry.
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M12, gap 24: a board keeps each ended vote's results in the file (the last 20), as the reference app keeps each voting session's results until they're deleted. A record holds the vote, its ballots, the names the voters went by and when it ended, so someone who has left a live session still shows by name. The Results popover lists them newest first, with arrows to earlier votes and Delete results (`board.vote {action: delete, index}`). Clear results still only takes the results on the board off it. `board.voteResults` lists `sessions`. The results are saved (`vote_history`; older files open without it, and a vote ended in one still shows), synced in live sessions (`DocProp::VoteHistory`), and like the vote they're not undo steps: undo and redo carry them over. Tests: `vote_history_tests`, `facilitate_tests` and `vote_history_ui_tests`. |
| 2026-10-30 | minor | M12, gap 24: an ended vote's results list who voted for each object and how many times ("Ada ×2, Bo"), as the reference app shows how each participant voted once a vote ends; while it runs, people still see only their own votes. `board.voteResults` gives them per result as `voters: [{voter, name, votes}]`. In a live session a voter is named as the session knows them, and someone who has left shows as such. The Voting row no longer asks for facilitator-only ending: the reference app's help says anyone in the file can end a vote, as here. Tests: `facilitate_tests` (offline and live, including a person who left) and `vote_voters_tests` (the Results popover). |
| 2026-10-30 | minor | The object a stamp will stick to is outlined while the stamp tool is over it and while a stamp is dragged over it, as the incumbent highlights it; a drag no longer drops anything into a sticky, shape with text or table (board objects take no layers in). Numbers unchanged |
| 2026-10-30 | minor | Stamps stick to the object they land on and move with it, as the incumbent's help describes; dragged onto another object they stick to that one, and they stay put, on nothing, when their object is deleted (the incumbent's help doesn't say). Numbers unchanged |
| 2026-10-30 | minor | The contextual bar row: a selected connector's bar sets its start and end points, solid or dashed, its weight and any colour, beside the line type and the board colours (#1596). Numbers unchanged |
+8 -5
View File
@@ -162,9 +162,9 @@ As built:
logged replacement or removal is left out too — rather than failing, and a file published to a
server is kept without them; a restarted server that left image entries out, or whose saved log
puts images, catches every resuming client up with its snapshot instead of that log); 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),
board's timer, vote and the results of earlier votes (whole values, `DocProp::Timer`,
`DocProp::Vote` and `DocProp::VoteHistory`; the app keeps the last 20 votes' results) 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
@@ -272,7 +272,7 @@ Server → client:
| Message | Meaning |
|---|---|
| `Welcome {session, client_id, seq, catch_up: Snapshot or Delta, processed, peers, access}` | Join accepted: the full document, or for a reconnecting client the batches it missed; what the server did with that client's batches (`processed {last_batch, refused}`); `access` is `edit`, `view` (a view link: every batch is refused) or `comment` (a comment link: a batch is refused unless every change in it is to comment threads: a thread's `put` or `remove`, their order or the whole list). |
| `Welcome {session, client_id, seq, catch_up: Snapshot, Delta or Packed, processed, peers, access}` | Join accepted: the full document, or for a reconnecting client the batches it missed; `Packed` is the full document compressed (deflate of the snapshot's postcard bytes), sent only to clients whose hello announced `packed`, which read it back within the size an uncompressed welcome could have had; what the server did with that client's batches (`processed {last_batch, refused}`); `access` is `edit`, `view` (a view link: every batch is refused) or `comment` (a comment link: a batch is refused unless every change in it is to comment threads: a thread's `put` or `remove`, their order or the whole list). |
| `Changes {seq, author, batch_id, changes}` | Accepted changes, in order, to everyone (the author gets its own back as confirmation). |
| `Reject {batch_id, reason, fixes: [Change]}` | A batch (or part of it) was invalid: the current values to roll back to. |
| `Presence {client_id, …}` / `PeerJoined` / `PeerLeft` | Other people's cursors, selections and arrivals. |
@@ -910,7 +910,10 @@ Decisions, as built:
10 000, `max_log_changes`, default 1 000 000, and `max_log_bytes`, default 64 MiB, which a
desktop host uses as they are and `figcraft-server` takes from its `max_log_mb`). If every batch
after the client's sequence number is still in it, the welcome carries them (`CatchUp::Delta`);
otherwise the whole document (`CatchUp::Snapshot`).
otherwise the whole document (`CatchUp::Snapshot`), compressed for a client whose hello
announced `packed` (`CatchUp::Packed`, `message::pack` / `unpack`: deflate at its fastest level
on the hub's thread; a document's changes shrink about sevenfold, e.g. 397 KB to 53 KB for 4 040
layers). Older clients, and servers from before, keep the uncompressed snapshot.
- **What happened to the batches in flight.** The server remembers per client the last batch id it
processed and the latest refused ids (at most 1 024). Batches up to that id that the catch-up
doesn't confirm were refused (the user is told); the others are sent again, in order, keeping
+2 -1
View File
@@ -1,6 +1,6 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; 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,
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. |
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1074: `figcraft-server` keeps a file everyone left loaded for a minute (at most 16 files, 256 MiB together), so rejoining it reads nothing, and writes a file's open time at most hourly (and when it stops) instead of at every join and leave: join/leave cycles of an unchanged file do no disk I/O on the hub thread. Edits are saved as before. Numbers unchanged. |
+12 -4
View File
@@ -17,7 +17,11 @@ reply: {"id": 1, "ok": true, "result": ...}
```
Requests are queued and run on the UI thread at the start of the next frame, so replies reflect the
real UI state. A request that the UI doesn't answer within 60 s gets a timeout error. A line that is
real UI state. A request that the UI doesn't answer within 60 s gets a timeout error that says what
happened to it: one the app never started (it was busy, or its window wasn't drawing) was dropped
and not applied; one it had started (a long `batch`, say) can't be stopped, so the error says it is
still running and will take effect, and to check the document before retrying; only `ui.screenshot`
and `ui.input`, which need a drawn frame, point at the window being minimised or covered. A line that is
not JSON gets `{"id": null, "ok": false, "error": "bad JSON: …"}` and ends the connection (so an
HTTP request a web page makes the browser send here never runs). The implementation is in
`crates/ui-egui/src/control.rs` (methods) and `apps/figcraft/src/control_server.rs` (socket).
@@ -35,8 +39,8 @@ before the newline** and drops the connection on a longer one (see [MCP server](
Both the socket queue and the UI queue hold at most **8 requests** each, including requests waiting
for another window's frame. A full socket queue replies with `"control request queue is full"`
instead of dispatching the new request. These limits apply to the opt-in loopback control channel;
requests still queued after the UI timeout are discarded, including requests deferred to a hidden
window. A command already running is not interrupted.
requests still queued after the UI timeout are discarded (never run), including requests deferred to
a hidden window. A command already running is not interrupted: it takes effect when it finishes.
A minimised or covered window (the main one or another, see Windows below) draws no frames, but
still answers every method that acts on the app's state (`engine.execute`, `document.inspect`,
@@ -89,7 +93,11 @@ read or write root and written only inside a write root; anything else is an ord
resolved as `docs/mcp.md` › Confining file access describes. A root that isn't an existing folder
stops the app before its window opens, and so does any option the app doesn't know (a mistyped
flag such as `--automation-write-roots` exits with `unknown option`, never starts unconfined).
Without the flags nothing changes. The flags arrived in FigCraft 0.7.0: 0.6.0 and earlier ignore
Confined work reaches no FigCraft or plugin server either: hosting, joining, publishing and
sharing files, team libraries on a server and plugin directories are refused (`automation roots
are set: commands don't reach the network …`). A root on a network share (a UNC path such as
`\\server\share\…`, or a mounted network drive) is the exception: files read and written inside it,
documents saved there included, go to that file server. Without the flags nothing changes. The flags arrived in FigCraft 0.7.0: 0.6.0 and earlier ignore
them and confine nothing.
Not confined: opening the files given on the command line when the app starts and the documents
+21 -6
View File
@@ -93,6 +93,16 @@ Each may be given more than once, as `--flag DIR` or `--flag=DIR`:
compared by whole names: `/work2/x` is not inside `/work`. On Windows the case comes from the disk (`C:\WORK\x` is
inside `C:\work`), `\\?\` and UNC (`\\server\share\…`) paths are resolved like drive-letter ones,
and new names that are devices (`CON`, `NUL`, `COM1`…) or streams (`a.png:x`) are refused.
- **The network:** a confined call reaches no FigCraft or plugin server, so it can't send a
document anywhere through one. Hosting, joining, publishing and sharing a file (`collab.host`,
`collab.join`, `collab.publish`, `collab.share` …), team libraries on a server (`library.check`
refuses, and `library.updates` / `library.list` report the refusal for each instead of the last
version fetched) and plugin directories are refused with `automation roots are set: commands
don't reach the network …`. The app started with the flags confines its own window too, so its
sharing is off as well. One exception: a root on a network share (a UNC path such as
`\\server\share\…`, or a mounted network drive) is still a root, so reading and writing inside
it reaches that file server, and saving there sends the document to it. Give only roots you would
let the agent's files go to.
- Not confined: the app's own folders, which no tool names (preferences, Data Recovery copies,
plugins installed from a plugin directory, the system fonts).
- `--attach` with either flag is a usage error: the app runs the commands, so only the app can
@@ -523,20 +533,25 @@ design file they refuse.
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
- `board.vote {action: start|cast|retract|end|clear|delete, …}` → 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)
`end` shows everyone's votes and keeps the results in the file (the last 20 votes'), `clear` takes the vote and its
results off the board (kept results stay), `delete {index}` removes kept results (0 the oldest). `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.
[{id, votes}], voters, results?, sessions}` (off: `{state, sessions}`): 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, voters}]`, most votes
first (ties share a rank), with `name` the sticky's text or the layer's name and `voters` who
voted for it: `[{voter, name, votes}]`, `voter` as the ballots key it (a live-session client as
`peer <id>`, else a name) and `name` what they go by (`null` for someone no longer in the live
session). `sessions` lists every ended vote's results, newest first:
`[{index (in the kept results, 0 the oldest; null for a vote that ended before results were kept), onBoard, ended (ms
since 1970), votesPerPerson, on, by, results}]`. Votes on deleted objects don't count.
```json
{"command": "board.timer", "params": {"action": "start", "minutes": 10}}
+6 -1
View File
File diff suppressed because one or more lines are too long
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7)
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -89,7 +89,7 @@ interaction feel not yet compared.
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) |
| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) |
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) |
| Comment (C), Shift+C to show/hide | done | local only |
| Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only |
| Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar |
@@ -175,6 +175,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M4, gap 4: the eyedropper with Cmd/Ctrl+Shift held makes a colour style or variable of the colour it picks, as the reference app's eyedropper does, instead of filling the selection. It asks the UI for a Create from color window (`dialog.createFromColor`; the control channel never opens it). The window shows the colour and offers Style or Variable, a name (the next free "Color n") and, for a variable, a collection: the file's first, or a new one made with it in the same undo step. Create runs `style.create` or `variable.create`. Tests: `eyedropper_create_tests`, `sampled_color_tests`. |
| 2026-10-30 | minor | M1: sidebar number and text fields keep their text vertically centered when focused, including typography spacing. Editors use the full 24 pt field height instead of adding a 4 pt top inset to an editor already at least 24 pt tall. Regression: `field_tests::sidebar_field_text_stays_in_place_on_focus` compares painted text positions before and after focus. Readiness estimates unchanged. |
| 2026-10-30 | minor | Pen into vector edit mode (#1430): Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on; Escape keeps the pen. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. |