diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9ca8e5e..28026c6 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -12,10 +12,6 @@ deck and UI icons are original work of the DeckCraft contributors (drawn or gene | Asset | Author | Source | Licence | Used for | |---|---|---|---|---| -| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit (project owner) | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` (not open source) | README header | -| `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README header (dark mode) | -| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About | -| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds | | `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks | | `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn | | `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` | diff --git a/README.md b/README.md index f25aef2..27ed0e5 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@

- - - ArtCraft - + DeckCraft Online — community Agent build

@@ -281,6 +278,6 @@ Forks and modified versions must remove them. Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DeckCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.

- ArtCraft
+ Based on DeckCraft by the ArtCraft team.
Made by the ArtCraft team and community.

diff --git a/ROADMAP.md b/ROADMAP.md index 46a87ad..688532d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -158,3 +158,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha | | 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md | | 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity | + +## Community browser bridge — 2026-10-17 + +The deckcraft.run community build adds an opt-in, guarded browser command bridge over existing Session commands and native undo. It reads slides, edits plain text and notes, inserts text boxes and adds slides. This is a site-maintained v0.7.0 patch; upstream parity estimates are unchanged. Validation and deployment evidence is maintained with the site patch. diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml index 7014421..c202f13 100644 --- a/apps/deckcraft-web/Cargo.toml +++ b/apps/deckcraft-web/Cargo.toml @@ -27,4 +27,4 @@ rfd = { workspace = true } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" js-sys = "0.3" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget"] } diff --git a/apps/deckcraft-web/src/api.rs b/apps/deckcraft-web/src/api.rs new file mode 100644 index 0000000..5de5707 --- /dev/null +++ b/apps/deckcraft-web/src/api.rs @@ -0,0 +1,130 @@ +//! Community web command bridge for deckcraft.run (2026-10-17). +//! Uses the existing UI control queue; no network listener or file-upload service. +use deckcraft_ui_egui::ControlRequest; +use serde_json::{Value, json}; +use std::cell::RefCell; +use std::sync::mpsc::{Receiver, Sender, TryRecvError}; +use wasm_bindgen::prelude::*; + +type Waiting = (Receiver, js_sys::Function, js_sys::Function); +thread_local! { + static DIRTY: std::cell::Cell = const { std::cell::Cell::new(false) }; + static TX: RefCell>> = const { RefCell::new(None) }; + static WAITING: RefCell> = const { RefCell::new(Vec::new()) }; + static CTX: RefCell> = const { RefCell::new(None) }; +} + +fn from_js(v: &JsValue) -> Result { + if v.is_null() || v.is_undefined() { + return Ok(json!({})); + } + let text = js_sys::JSON::stringify(v)?.as_string().ok_or_else(|| js_sys::Error::new("parameters must be JSON"))?; + if text.len() > 1_048_576 { + return Err(js_sys::Error::new("request exceeds 1 MiB").into()); + } + let value: Value = serde_json::from_str(&text).map_err(|e| js_sys::Error::new(&e.to_string()))?; + if !value.is_object() { + return Err(js_sys::Error::new("parameters must be an object").into()); + } + Ok(value) +} + +fn request(method: String, params: Value) -> js_sys::Promise { + if !matches!(method.as_str(), "engine.execute" | "agent.execute" | "engine.commands" | "agent.state" | "agent.export") { + return js_sys::Promise::reject(&js_sys::Error::new("unsupported web method")); + } + if WAITING.with(|w| w.borrow().len() >= 32) { + return js_sys::Promise::reject(&js_sys::Error::new("too many pending requests")); + } + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let (req, rx) = ControlRequest::new(method.clone(), params.clone()); + if TX.with(|t| t.borrow().as_ref().is_some_and(|tx| tx.send(req).is_ok())) { + WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject))); + } else { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("DeckCraft is not ready")); + } + }); + CTX.with(|c| { + if let Some(ctx) = c.borrow().as_ref() { + ctx.request_repaint(); + } + }); + promise +} + +pub fn poll_replies(dirty: bool) { + DIRTY.with(|d| d.set(dirty)); + WAITING.with(|w| { + w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() { + Ok(v) => { + if v.get("ok").and_then(Value::as_bool) == Some(true) { + match js_sys::JSON::parse(&v.get("result").unwrap_or(&Value::Null).to_string()) { + Ok(value) => { + let _ = resolve.call1(&JsValue::NULL, &value); + } + Err(error) => { + let _ = reject.call1(&JsValue::NULL, &error); + } + } + } else { + let error = js_sys::Error::new(v.get("error").and_then(Value::as_str).unwrap_or("command failed")); + let _ = reject.call1(&JsValue::NULL, &error); + } + false + } + Err(TryRecvError::Empty) => true, + Err(TryRecvError::Disconnected) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("editor closed")); + false + } + }) + }); +} + +pub fn install(ctx: egui::Context) -> Result, String> { + let (tx, rx) = std::sync::mpsc::channel(); + TX.with(|t| *t.borrow_mut() = Some(tx)); + CTX.with(|c| *c.borrow_mut() = Some(ctx)); + let obj = js_sys::Object::new(); + let set = |name: &str, value: JsValue| js_sys::Reflect::set(&obj, &name.into(), &value).map_err(|_| format!("could not install {name}")); + set("apiVersion", "craft-online-1".into())?; + set("hasUnsavedChanges", Closure:: bool>::new(|| DIRTY.with(|d| d.get())).into_js_value())?; + set( + "request", + Closure:: js_sys::Promise>::new(|method: String, params: JsValue| match from_js(¶ms) { + Ok(p) => request(method, p), + Err(e) => js_sys::Promise::reject(&e), + }) + .into_js_value(), + )?; + set( + "execute", + Closure:: js_sys::Promise>::new(|command: String, params: JsValue, expected: JsValue| { + let parsed = from_js(¶ms).and_then(|p| { + let mut value = json!({"command": command, "params": p}); + if !expected.is_undefined() && !expected.is_null() { + value["expected"] = from_js(&expected)?; + } + Ok(value) + }); + match parsed { + Ok(p) => request(if p.get("expected").is_some() { "agent.execute" } else { "engine.execute" }.into(), p), + Err(e) => js_sys::Promise::reject(&e), + } + }) + .into_js_value(), + )?; + set("commands", Closure:: js_sys::Promise>::new(|| request("engine.commands".into(), json!({}))).into_js_value())?; + set("inspect", Closure:: js_sys::Promise>::new(|| request("agent.state".into(), json!({}))).into_js_value())?; + let win = web_sys::window().ok_or("no window")?; + use wasm_bindgen::JsCast as _; + let guard = Closure::::new(|event: web_sys::Event| { + if DIRTY.with(|d| d.get()) { + event.prevent_default(); + } + }); + win.add_event_listener_with_callback("beforeunload", guard.as_ref().unchecked_ref()).map_err(|_| "could not install unsaved-work guard")?; + guard.forget(); + js_sys::Reflect::set(&win, &"deckcraft".into(), &obj).map_err(|_| "could not install window.deckcraft")?; + Ok(rx) +} diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs index 366ea34..e302518 100644 --- a/apps/deckcraft-web/src/main.rs +++ b/apps/deckcraft-web/src/main.rs @@ -17,6 +17,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index f0199a5..f604cfa 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -39,6 +39,9 @@ pub fn start() { let inbox: Inbox = Inbox::default(); let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone())); let q = query(); + if q.trim_start_matches('?').split('&').any(|p| p == "agent=1") { + app = app.with_control(crate::api::install(cc.egui_ctx.clone())?); + } if !q.contains("blank") { if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) { log::error!("sample deck: {e}"); @@ -91,6 +94,7 @@ impl eframe::App for WebShell { }); } self.app.logic(ctx); + crate::api::poll_replies(self.app.session.documents().iter().any(|d| d.is_dirty())); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { diff --git a/crates/ui-egui/src/community_agent.rs b/crates/ui-egui/src/community_agent.rs new file mode 100644 index 0000000..6e9f16c --- /dev/null +++ b/crates/ui-egui/src/community_agent.rs @@ -0,0 +1,232 @@ +//! Community in-page Agent: bounded tools over the live command engine and native history. +use crate::SlideApp; +use deckcraft_engine::Target; +use deckcraft_model::{ + ShapeKind, + text::{RunKind, TextBody}, +}; +use serde_json::{Value, json}; +fn index(p: &Value, key: &str) -> Result { + p.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or_else(|| format!("{key} must be a non-negative integer")) +} +fn text<'a>(p: &'a Value, key: &str) -> Result<&'a str, String> { + p.get(key).and_then(Value::as_str).filter(|s| s.len() <= 16000).ok_or_else(|| format!("{key} must be text of at most 16000 UTF-8 bytes")) +} +fn simple(body: &TextBody) -> bool { + body.paragraphs.iter().flat_map(|p| p.runs.iter()).all(|r| matches!(r.kind, RunKind::Text | RunKind::Break) && r.props.link.is_none()) +} +impl SlideApp { + fn agent_token(&self) -> Result { + if self.dialog.is_some() || self.backstage_open() || self.show.is_some() { + return Err("Finish or cancel the dialog or slide show before using the Agent.".into()); + } + let (uid, slide, buf) = &self.notes_buf; + if self.session.documents().iter().find(|d| d.uid == *uid).and_then(|d| d.doc.slides.get(*slide)).is_some_and(|s| s.notes.text() != *buf) { + return Err("Finish or cancel the current speaker-note edit before using the Agent.".into()); + } + let d = self.session.doc().map_err(|e| e.to_string())?; + if d.interaction.is_some() || d.composition.is_some() || d.selection.target != Target::Slides { + return Err("Finish or cancel the current edit and return to normal slides before using the Agent.".into()); + } + Ok(json!({"uid":d.uid.to_string(),"revision":d.revision.to_string()})) + } + fn agent_state(&self, p: &Value) -> Result { + let token = self.agent_token()?; + let d = self.session.doc().map_err(|e| e.to_string())?; + let at = if p.get("slide").is_some() { index(p, "slide")?.checked_sub(1).ok_or("slide numbers start at 1")? } else { d.selection.slide }; + let start = if p.get("start_shape").is_some() { index(p, "start_shape")? } else { 0 }; + let count = if p.get("count").is_some() { index(p, "count")? } else { 30 }; + if !(1..=40).contains(&count) { + return Err("count must be 1–40".into()); + } + let mut budget = 24000usize; + let slide = if let Some(sl) = d.doc.slides.get(at) { + let shapes:Vec=sl.shapes.iter().skip(start).take(count).map(|sh|{ + let raw=sh.text.as_ref().map(TextBody::text).unwrap_or_default();let complete=raw.len()<=16000&&raw.len()<=budget; + let shown=if complete{budget-=raw.len();raw}else{String::new()}; + let editable=matches!(sh.kind,ShapeKind::Shape)&&!sh.locked&&!sh.hidden&&complete&&sh.text.as_ref().is_some_and(simple); + json!({"id":sh.id.0,"name":sh.name.chars().take(200).collect::(),"text":shown,"editable":editable,"truncated":!complete,"rect":sh.xfrm.map(|r|[r.x,r.y,r.w,r.h])}) + }).collect(); + let notes = sl.notes.text(); + let notes_complete = notes.len() <= 16000; + json!({"number":at+1,"id":sl.id.0,"shapes":shapes,"totalShapes":sl.shapes.len(),"notes":if notes_complete{notes}else{String::new()},"notesEditable":notes_complete&&simple(&sl.notes),"notesTruncated":!notes_complete}) + } else if d.doc.slides.is_empty() { + Value::Null + } else { + return Err("slide is outside the presentation".into()); + }; + Ok( + json!({"token":token,"document":{"slideCount":d.doc.slides.len(),"width":d.doc.slide_size.width,"height":d.doc.slide_size.height,"canUndo":!d.history.undo.is_empty(),"canRedo":!d.history.redo.is_empty(),"dirty":d.is_dirty(),"slide":slide}}), + ) + } + pub(crate) fn community_agent(&mut self, method: &str, p: &Value) -> Result { + let token = self.agent_token()?; + if let Some(expected) = p.get("expected") + && expected != &token + { + return Err("document changed since inspection; start a new request".into()); + } + if method == "agent.state" { + return self.agent_state(p); + } + if p.get("expected").is_none() { + return Err("expected document state required".into()); + } + if method == "agent.export" { + return if self.export_as("pptx", json!({})) { + Ok(json!({"downloaded":true})) + } else { + Err("PPTX export failed; check the editor status".into()) + }; + } + let cmd = p.get("command").and_then(Value::as_str).ok_or("missing command")?; + let args = p.get("params").filter(|v| v.is_object()).ok_or("params must be an object")?; + let d = self.session.doc().map_err(|e| e.to_string())?; + let previous = d.selection.clone(); + let (native, params, target) = match cmd { + "undo" => { + if d.history.undo.is_empty() { + return Err("There is no edit to undo.".into()); + } + ("edit.undo", json!({}), None) + } + "add_slide" => { + let at = index(args, "at")?.checked_sub(1).ok_or("slide positions start at 1")?; + if at > d.doc.slides.len() { + return Err("at must be an existing slide position or slideCount + 1".into()); + } + let title = text(args, "title")?; + let body = text(args, "body")?; + ("slide.new", json!({"layout":"titleAndContent","at":at,"title":title,"body":body}), None) + } + "set_text" | "set_notes" | "add_text" => { + let at = index(args, "slide")?.checked_sub(1).ok_or("slide numbers start at 1")?; + let sl = d.doc.slides.get(at).ok_or("slide is outside the presentation")?; + let value = text(args, "text")?; + if cmd == "set_notes" { + if !simple(&sl.notes) || sl.notes.text() != text(args, "expected_text")? { + return Err("Read the current plain notes before replacing them.".into()); + } + if sl.notes.text() == value { + return Err("Replacement is unchanged.".into()); + } + ("slide.notes", json!({"index":at,"text":value}), None) + } else if cmd == "set_text" { + let id = u32::try_from(index(args, "shape_id")?).map_err(|_| "invalid shape_id")?; + let sh = sl.shapes.iter().find(|s| s.id.0 == id).ok_or("shape is not on the requested slide")?; + let body = sh.text.as_ref().ok_or("shape has no text")?; + if sh.locked || sh.hidden || !matches!(sh.kind, ShapeKind::Shape) || !simple(body) { + return Err("Only unlocked, visible plain text shapes are editable; groups, tables, fields and links are excluded.".into()); + } + if body.text() != text(args, "expected_text")? { + return Err("Shape text changed; read the slide again.".into()); + } + if body.text() == value { + return Err("Replacement is unchanged.".into()); + } + ("text.set", json!({"id":id,"text":value}), Some(at)) + } else { + let rect = args.get("rect").and_then(Value::as_array).filter(|a| a.len() == 4).ok_or("rect must be [x,y,width,height]")?; + let n: Vec = rect + .iter() + .map(|v| v.as_f64().filter(|v| v.is_finite()).ok_or("rectangle values must be finite")) + .collect::>()?; + let [x, y, w, h] = n.as_slice() else { + return Err("invalid rectangle".into()); + }; + if value.trim().is_empty() + || *x < 0.0 + || *y < 0.0 + || *w < 1.0 + || *h < 1.0 + || x + w > d.doc.slide_size.width + || y + h > d.doc.slide_size.height + { + return Err("The text box must fit inside the slide.".into()); + } + ("insert.textBox", json!({"rect":n,"text":value}), Some(at)) + } + } + _ => return Err("unsupported Agent command".into()), + }; + if let Some(at) = target { + self.run("slide.go", json!({"index":at}))?; + } + let result = self.run(native, params); + if result.is_err() + && let Some(d) = self.session.active_mut() + { + d.selection = previous; + } + let result = result?; + if let Some(d) = self.session.active() { + self.notes_buf = (d.uid, d.selection.slide, d.current_slide().map(|s| s.notes.text()).unwrap_or_default()); + } + // Notes can target a slide without moving the canvas. Return that slide for verification. + let state_args = if cmd == "set_notes" { json!({"slide":args.get("slide")}) } else { json!({}) }; + Ok(json!({"result":result,"state":self.agent_state(&state_args)?})) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + use deckcraft_engine::Session; + fn app() -> SlideApp { + let mut s = Session::new(); + s.execute("file.new", &json!({"blank":true})).unwrap(); + s.execute("slide.new", &json!({"title":"Original 中文","body":"Keep this body"})).unwrap(); + s.execute("slide.new", &json!({"title":"Other slide","body":"Untouched"})).unwrap(); + SlideApp::new(s, Services::default()) + } + #[test] + fn edits_real_slides_pptx_notes_and_undo() { + let mut a = app(); + let s = a.agent_state(&json!({"slide":1})).unwrap(); + let id = s["document"]["slide"]["shapes"].as_array().unwrap().iter().find(|v| v["text"] == "Original 中文").unwrap()["id"].clone(); + let p = json!({"command":"set_text","params":{"slide":1,"shape_id":id,"expected_text":"Original 中文","text":"Revised 中文"},"expected":s["token"]}); + let r = a.community_agent("agent.execute", &p).unwrap(); + assert!(a.community_agent("agent.execute", &p).unwrap_err().contains("document changed")); + let d = a.session.doc().unwrap(); + let bytes = deckcraft_engine::cmd::file::save_bytes(&d.doc, "pptx").unwrap(); + let reopened = deckcraft_engine::cmd::file::open_presentation("saved.pptx", &bytes).unwrap(); + assert!(reopened.slides.first().unwrap().shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text() == "Revised 中文"))); + assert!(reopened.slides.get(1).unwrap().shapes.iter().any(|x| x.text.as_ref().is_some_and(|t| t.text() == "Untouched"))); + let n = a + .community_agent( + "agent.execute", + &json!({"command":"set_notes","params":{"slide":1,"expected_text":"","text":"Speaker notes"},"expected":r["state"]["token"]}), + ) + .unwrap(); + assert_eq!(n["state"]["document"]["slide"]["notes"], "Speaker notes"); + let u = a.community_agent("agent.execute", &json!({"command":"undo","params":{},"expected":n["state"]["token"]})).unwrap(); + assert_eq!(u["state"]["document"]["slide"]["notes"], ""); + let u = a.community_agent("agent.execute", &json!({"command":"undo","params":{},"expected":u["state"]["token"]})).unwrap(); + assert!(u.to_string().contains("Original 中文")); + a.run("file.new", json!({})).unwrap(); + assert!( + a.community_agent("agent.execute", &json!({"command":"undo","params":{},"expected":u["state"]["token"]})) + .unwrap_err() + .contains("document changed") + ); + } + #[test] + fn rejects_wrong_targets_and_pending_notes_without_edits() { + let mut a = app(); + let s = a.agent_state(&json!({"slide":1})).unwrap(); + let before = a.session.doc().unwrap().doc.clone(); + for (cmd, p) in [ + ("set_text", json!({"slide":1,"shape_id":999,"expected_text":"","text":"Bad"})), + ("add_text", json!({"slide":1,"text":"Bad","rect":[-1,0,100,30]})), + ("add_slide", json!({"at":999,"title":"Bad","body":""})), + ("file.close", json!({})), + ] { + assert!(a.community_agent("agent.execute", &json!({"command":cmd,"params":p,"expected":s["token"]})).is_err()); + } + assert!(std::sync::Arc::ptr_eq(&before, &a.session.doc().unwrap().doc)); + let d = a.session.doc().unwrap(); + a.notes_buf = (d.uid, 0, "Uncommitted note".into()); + assert!(a.agent_state(&json!({})).unwrap_err().contains("speaker-note")); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d344d91..d94f170 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -106,6 +106,7 @@ pub fn handle(app: &mut SlideApp, ctx: &egui::Context, req: &ControlRequest) -> let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { + "agent.state" | "agent.execute" | "agent.export" => wrap(app.community_agent(&req.method, p)), "engine.execute" | "ui.menu.invoke" | "command" => { let Some(id) = s("command").or(s("id")) else { return err("missing `command`") }; let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ec97e6e..4a55979 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ pub mod backstage; pub mod canvas; +mod community_agent; pub mod control; pub mod credits; pub mod dialogs; diff --git a/docs/roadmap.md b/docs/roadmap.md index 9795303..b217f08 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -111,3 +111,7 @@ plan above. |---|---|---| | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | + +## Community browser bridge — 2026-10-17 + +The deckcraft.run community build adds an opt-in, guarded browser command bridge over existing Session commands and native undo. It reads slides, edits plain text and notes, inserts text boxes and adds slides. This is a site-maintained v0.7.0 patch; upstream parity estimates are unchanged. Validation and deployment evidence is maintained with the site patch.