Makepad-skills makepad-2.0-splash
git clone https://github.com/ZhangHanDong/makepad-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/ZhangHanDong/makepad-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/makepad-2.0-splash" ~/.claude/skills/zhanghandong-makepad-skills-makepad-2-0-splash && rm -rf "$T"
skills/makepad-2.0-splash/SKILL.mdMakepad 2.0 Splash Scripting Language
Splash is Makepad 2.0's core runtime UI scripting language, released February 12, 2026. It replaces the old compile-time
live_design! macro system with a runtime script_mod! macro that enables hot reload, streaming evaluation, and AI-first code generation.
Core Concepts
Script Structure
Every Splash script starts with a
use import and is embedded in Rust via the script_mod!{} macro:
use makepad_widgets::*; app_main!(App); script_mod! { use mod.prelude.widgets.* // let bindings, functions, state, and UI definitions go here startup() do #(App::script_component(vm)){ ui: Root{ main_window := Window{ window.inner_size: vec2(800, 600) body +: { // UI content } } } } }
Syntax Rules
- No commas between properties -- whitespace-delimited
- No semicolons -- cleaner syntax optimized for LLM generation
- Property assignment:
key: value - Dot-path shorthand:
(equivalent todraw_bg.color: #f00
)draw_bg +: { color: #f00 } - Merge operator:
extends parent without replacingkey +: { ... } - Named children:
(addressable, overridable per-instance)name := Widget{...} - Let bindings:
(local scope, must be defined before use)let MyTemplate = Widget{...} - Rust binding:
connects Splash to Rust structs#(Struct::register_widget(vm)) - Debug logging:
logs value during evaluation~expression
State Management
State is managed via the
mod.state object and reactive on_render callbacks:
// Define state let state = { counter: 0 } mod.state = state // Reactive rendering -- re-runs when .render() is called main_view := View{ on_render: ||{ Label{ text: "Count: " + state.counter } } }
Event Handling
Events are handled both inline in Splash and from Rust:
// Inline event handlers in Splash add_button := Button{ text: "Add" on_click: ||{ add_todo(ui.todo_input.text(), "") ui.todo_input.set_text("") } } // TextInput return key todo_input := TextInput{ on_return: || ui.add_button.on_click() } // Startup event on_startup: ||{ ui.main_view.render() }
From Rust, use
script_eval! to execute Splash code:
impl MatchEvent for App { fn handle_actions(&mut self, cx: &mut Cx, actions: &Actions) { if self.ui.button(cx, ids!(increment_button)).clicked(actions) { script_eval!(cx, { mod.state.counter += 1 ui.main_view.render() }); } } }
Functions
fn tag_color(tag) { if tag == "dev" theme.color_highlight else if tag == "design" theme.color_selection_focus else theme.color_highlight } fn add_todo(text, tag) { todos.push({text: text, tag: tag, done: false}) ui.todo_list.render() }
Control Flow
// If/else if todos.len() == 0 EmptyState{} else for i, todo in todos { TodoItem{ label.text: todo.text } } // For loops for i, item in array { Label{ text: item.name } } // While while condition { ... }
HTTP Requests
let req = net.HttpRequest{ url: "https://api.example.com/data" method: net.HttpMethod.GET headers: {"User-Agent": "MakepadApp/1.0"} } net.http_request(req) do net.HttpEvents{ on_response: |res| { let text = res.body.to_string() let json = res.body.parse_json() } on_error: |e| { /* handle error */ } }
Streaming responses use
is_streaming: true with on_stream and on_complete callbacks.
HTML Parsing
let doc = html_string.parse_html() doc.query("p") // all <p> elements doc.query("#main") // by id doc.query("p.bold") // by class doc.query("div > p") // direct children doc.query("p[0]").text // text content doc.query("a@href") // attribute value
Streaming Evaluation
Splash's parser supports checkpoint-based incremental parsing, designed for AI/LLM streaming code generation:
// Rust API for streaming evaluation vm.eval_with_append_source(script_mod, &code, NIL.into())
This enables real-time UI updates as code is generated token-by-token, without requiring a complete script before evaluation.
Hot Reload & Script Mod Tracking
Splash scripts support hot reload via the
--hot flag. The VM tracks each script_mod! block with a unique ScriptModKey (file, line, column):
// Internal: ScriptModKey uniquely identifies a script_mod! block ScriptModKey { file: "src/app.rs", line: 5, col: 1 } // Runtime substitution via overrides ScriptCode::script_mod_overrides // HashMap of ScriptModKey -> updated source
How hot reload works:
- File watcher (
) detects source file changesmakepad_live_reload_core
blocks are extracted from Rust source (handles raw strings, comments, char literals)script_mod!- Rust placeholder counts (
) are tracked -- adding/removing placeholders requires full rebuild#(...) - Validated script mods are applied via
script_mod_overrides - IP-to-location mapping provides source maps for error reporting (fallback to nearest token for synthetic opcodes)
ScriptSource variants:
-- Standard module evaluation (startup)ScriptSource::Mod
-- Incremental streaming evaluation (AI/LLM)ScriptSource::Streaming
Critical Layout Rules
- Always set
on containers -- defaultheight: Fit
causes invisible UI (0px height)height: Fill - Use
on the root container -- never fixed pixel width at the top levelwidth: Fill - Set
on any View withnew_batch: true
that contains text childrenshow_bg: true - Use
for named children in templates -- without it, text overrides fail silently:=
takes a float, not an Inset --draw_bg.border_radiusdraw_bg.border_radius: 16.0- Use styled Views (
,RoundedView
) instead of rawSolidViewView{show_bg: true}
Widget Reference
Core containers:
View, SolidView, RoundedView, RectView, RoundedShadowView, CircleView, GradientXView, GradientYView, ScrollXYView, ScrollXView, ScrollYView
Text:
Label, H1-H4, P, TextBox, TextInput, LinkLabel, Markdown, Html
Controls:
Button, ButtonFlat, ButtonFlatter, CheckBox, Toggle, RadioButton, Slider, DropDown
Layout:
Splitter, FoldHeader, Hr, Vr, Filler
Lists:
PortalList, FlatList
Navigation:
Modal, Tooltip, PopupNotification, SlidePanel, ExpandablePanel, PageFlip, StackNavigation
Dock:
Dock, DockSplitter, DockTabs, DockTab
Media:
Image, Icon, LoadingSpinner, Vector, MathView, MapView
Canvas: Rendering Splash from Claude Code
Makepad Canvas (
tools/canvas/) is a standalone app that renders Splash code received via HTTP/WS. Used by Claude Code for visual output.
HTTP API (recommended for sending Splash)
PORT=$(cat /tmp/makepad-canvas.port) # Full render curl -s -X POST "http://127.0.0.1:$PORT/splash" -d 'View{width:Fill height:Fit Label{text:"Hello"}}' # Streaming render curl -s -X POST "http://127.0.0.1:$PORT/splash/stream" # begin curl -s -X POST "http://127.0.0.1:$PORT/splash/stream" -d 'View{...' # append curl -s -X POST "http://127.0.0.1:$PORT/splash/end" # end # Clear curl -s -X POST "http://127.0.0.1:$PORT/clear"
WS Event Listening (for receiving click events)
# Long-lived WS connection receives button click events as JSON mkfifo /tmp/ws_fifo; (sleep 99999 > /tmp/ws_fifo &) websocat ws://127.0.0.1:$PORT < /tmp/ws_fifo > /tmp/canvas_events & # Events arrive as: {"event":"click","widget":"btn_name"}
Interactive Buttons
Use
name := Button{...} to create clickable buttons. The name is sent in click events:
View{width:Fill height:Fit flow:Right spacing:12 btn_save := Button{text:"Save"} btn_cancel := Button{text:"Cancel"} }
When clicked:
{"event":"click","widget":"btn_save"}
Vector Animations in Splash
// Pulsing dot (loop_:true = indefinite, NOT "indefinite"!) Vector{width:16 height:16 Circle{cx:8 cy:8 r:6 fill:#x44ddaa opacity:Tween{from:0.3 to:1.0 dur:1.5 loop_:true}} } // Moving dot with color change Vector{width:Fill height:30 Path{d:"M 20 15 L 400 15" stroke:#x222244 stroke_width:1.} Circle{cx:Tween{from:20 to:400 dur:3.0 loop_:true} cy:15 r:4 fill:Tween{from:#x44ddaa to:#xffaa44 dur:3.0 loop_:true}} }
Canvas Splash Syntax (CRITICAL -- differs from script_mod!)
When generating Splash for Canvas HTTP rendering (
POST /splash), use these EXACT patterns. Canvas Splash syntax differs from script_mod! macro context in several critical ways:
1. Properties use dot-path inline, NOT nested blocks:
// WRONG -- nested block syntax does not render backgrounds RoundedView{height: Fit draw_bg: { color: #x1a1a2e border_radius: 8.0 } } // CORRECT -- dot-path inline RoundedView{width: Fill height: Fit draw_bg.color: #x1a1a2e draw_bg.radius: 8.}
2. Border radius is
, NOT draw_bg.radius
:draw_bg.border_radius
// WRONG draw_bg.border_radius: 8.0 // CORRECT draw_bg.radius: 8.
3. Padding uses explicit
type with trailing-dot floats:Inset{}
// WRONG -- bare number or nested block padding: 20 padding: { top: 20 bottom: 20 } // CORRECT padding: Inset{left: 20. right: 20. top: 16. bottom: 16.}
4. Align uses explicit
type:Align{}
// WRONG align: { y: 0.5 } // CORRECT align: Align{y: 0.5} align: Center
5. Float values use trailing dot:
// WRONG // CORRECT 8.0 8. 16.0 16. 0.5 0.5
6.
and SolidView
do NOT need RoundedView
or show_bg: true
-- they render backgrounds out of the box.new_batch: true
7. Use
for multi-line Splash via curl -- plain --data-binary
-d strips newlines.
Proven Canvas Dashboard Template
Source:
tools/canvas/examples/token-dashboard.splash
SolidView{width: Fill height: Fit draw_bg.color: #x0c0c18 flow: Down padding: Inset{left: 32. right: 32. top: 24. bottom: 24.} spacing: 20 // Title Label{text: "Dashboard Title" draw_text.color: #xeeeeff draw_text.text_style.font_size: 20} // Card row View{width: Fill height: Fit flow: Right spacing: 16 RoundedView{width: Fill height: Fit draw_bg.color: #x161628 draw_bg.radius: 8. padding: Inset{left: 20. right: 20. top: 16. bottom: 16.} flow: Down spacing: 6 Label{text: "Metric Name" draw_text.color: #x888899 draw_text.text_style.font_size: 10} Label{text: "Value" draw_text.color: #xcc66ff draw_text.text_style.font_size: 28} } } // Horizontal bar chart row View{width: Fill height: Fit flow: Right spacing: 8 align: Align{y: 0.5} Label{text: "Label" width: 100 draw_text.color: #xbbbbbb draw_text.text_style.font_size: 10} RoundedView{width: 200 height: 12 draw_bg.color: #xf44336 draw_bg.radius: 2.} Label{text: "200" draw_text.color: #x777777 draw_text.text_style.font_size: 10} } // Vertical bar chart (bars bottom-aligned) View{width: Fill height: 130 flow: Right spacing: 2 align: Align{y: 1.0} View{width: Fill height: Fit flow: Down align: Center spacing: 4 RoundedView{width: 14 height: 80 draw_bg.color: #x7733cc draw_bg.radius: 2.} Label{text: "Mon" draw_text.color: #x444455 draw_text.text_style.font_size: 7} } } }
Canvas Tips
- HTTP for splash, WS for events — most reliable pattern
- Vector shape properties:
,fill
,stroke
— NOTstroke_widthdraw_bg.* - CJK/Chinese text: Supported in both body text and code blocks. CodeEditor uses double-width columns for CJK characters (fixed 2026-03-23). Theme
includes LXGWWenKai as Chinese fallback.font_code - Large POST bodies: Canvas supports up to 512KB HTTP body
- Pub/sub broadcast: All connected WS clients receive click events
- Health check: Use
(NOTGET /ping
)/health - Monitor toggle:
flag file controls statusline auto-refresh. Canvas sidebar has a toggle button./tmp/canvas-monitor-active
Compiled vs Eval: Two Fundamentally Different Widget Creation Paths
Understanding this distinction is CRITICAL for debugging any Splash eval issue:
Compiled path (script_mod!, examples, studio): parse → compile → execute → create_widget → script_apply(FULL type default object with all vec + map entries) → on_after_apply(value = full object) ✓ ← ScriptHook runs → Templates registered, #[live] fields populated from type default Eval path (POST /splash, Splash.set_text): parse → eval → create_widget_from_prototype → script_apply(EVAL VALUE ONLY, e.g. {body: "..."}) → on_after_apply: SKIPPED (is_eval() guard in TextFlow/Markdown) → Templates NOT registered, #[live] fields use defaults (not type default)
| Aspect | Compiled () | Eval () |
|---|---|---|
| Widget creation | Full Rust + ScriptVm init | ScriptVm eval string only |
| Called with full type default | NOT called |
| Type default vec (named children) | Fully inherited | May lose entries in proto copy |
| Type default map (properties) | Applied to fields | Only eval value applied |
| Template registration | Via ScriptHook during apply | Must be done lazily in draw_walk |
| Works | Renders blank — use |
Fix pattern for eval-path issues: Implement lazy initialization in
draw_walk that detects missing state and looks up the type default via cx.with_vm(|vm| vm.bx.heap.type_default_for_object(...)). This is how Markdown's code_block template inheritance was fixed.
Splash Eval Pitfalls (CRITICAL -- learned 2026-03-23)
These issues only affect widgets created via Splash runtime eval (
POST /splash, Splash.set_text()), NOT compiled script_mod! widgets:
1.
does NOT work in Splash eval -- renders blankScrollYView
// WRONG -- Splash eval renders nothing ScrollYView{ width: Fill height: Fill flow: Down Label{ text: "invisible" } } // CORRECT -- use View, Canvas wraps it in its own ScrollYView View{ width: Fill height: Fit flow: Down Label{ text: "visible" } }
2.
/ on_after_apply
is NOT called for eval-created widgetsScriptHook
When Splash eval creates a widget (e.g.
Markdown{body: "..."}), the ScriptHook::on_after_apply callback is never invoked. Any initialization that depends on on_after_apply must have a fallback path (e.g. lazy init in draw_walk).
3. Type default properties are NOT fully inherited in eval path
When
set_type_default() overrides a widget type with extra properties (like use_code_block_widget: true) or named children (like code_block := View{...}), instances created via Splash eval may not inherit these. The #[live] fields only get values from the eval apply value (e.g. {body: "..."}), not the full type default.
Workaround: Check for missing state in
draw_walk and look up the type default via vm.bx.heap.type_default_for_object().
4. Type default vec entries (named children) may not copy to instances
Even though
copy_type_default_vec exists, the vec entries from set_type_default() may lose entries between registration and instance creation. The auto-proto vec copy in new_with_proto_impl copies from the direct proto, which may have fewer entries than the type_default.
5. Nested
inside Splash works but needs type default templatesMarkdown{}
When Canvas overrides
mod.widgets.Markdown with code_block := View{CodeView{...}}, and Splash eval creates Markdown{body: "..."}, the code_block template is NOT automatically available. The fix (in widgets/src/markdown.rs) lazily looks up the type default at draw time and registers missing templates.
Debugging Splash
Command-Line Flags
| Flag | Description |
|---|---|
| Enable hot reload: watches source files and auto-refreshes UI on save. Only reloads Splash DSL; Rust changes need recompilation. |
| Studio mode: communicates with Makepad Studio via stdin/websocket. Used internally by Studio. |
Print Debugging
std.println() / std.print() are the primary debugging tools. Output goes to both terminal and Studio's Log View:
std.println("debug: state.counter = " + state.counter)
The
~expression debug log syntax also prints values during evaluation:
~state.counter // prints the value of state.counter during eval
Makepad Studio Integration
Studio can run Splash scripts directly from the Run List panel (looks for
makepad.splash in project root). Script errors appear in the Log View with file path and error details.
When running under Studio, Splash scripts get a
hub module:
| API | Description |
|---|---|
| Launch subprocess from Splash |
| Register runnable items in Studio's Run List |
| Studio's WebSocket address |
Current Limitations
- No breakpoint debugging -- Splash VM does not support breakpoints or stepping
- No AST dump flag -- Inspecting parse results requires adding logs in Rust source (
)script/ - Print-based debugging --
andstd.println()
are the primary debugging tools~expr
File References
- Full language manual:
(2559 lines)splash.md - Migration guide:
(815 lines)AGENTS.md - Counter example:
examples/counter/src/app.rs - Todo example:
examples/todo/src/app.rs - Detailed reference:
skills/makepad-2.0-splash/references/splash-language-reference.md - Patterns guide:
skills/makepad-2.0-splash/references/splash-scripting-patterns.md
Practical Splash Lessons (learned 2026-03-31, from building Vox voice input app)
Lesson 1: instance
Fields Cannot Be Added in +:
Blocks
instance+:The most critical Splash limitation. Adding
instance my_var: 0.5 inside a draw_bg +: { } block causes a runtime error: "cannot push to frozen vec".
// WRONG — runtime crash draw_bg +: { instance hover: 0.0 // CRASH: cannot push to frozen vec pixel: fn() { ... } } // CORRECT — override pixel function only, use built-in variables draw_bg +: { pixel: fn() { let t = self.draw_pass.time // built-in, always available return Pal.premul(vec4(t, 0.0, 0.0, 1.0)) } }
Workarounds:
- Use
for time-based animationself.draw_pass.time - Use
,self.pos
(always available)self.rect_size - For custom instance variables, create a Rust
subtype (see makepad-2.0-shaders skill)DrawQuad - Use
widget for simple animated indicatorsLoadingSpinner
Lesson 2: Transparent Floating Window Recipe
Creating a truly transparent overlay window requires three things:
my_window := Window{ show_caption_bar: false window.transparent: true // 1. Window-level transparency pass.clear_color: #x00000000 // 2. Render pass clear to fully transparent body +: { // 3. Do NOT set draw_bg on body — it overrides transparency View{ width: Fill height: Fill // Only visible elements show; rest is see-through } } }
Common mistakes:
- Setting only
— window stays opaque (gray background)window.transparent: true - Setting
on body — does NOT make it transparent, just blackdraw_bg.color: #x00000000 - Must also configure as floating panel from Rust:
+MacosWindowConfig::floating_panel()Borderless
Reference implementation: Makepad
tools/canvas/src/app.rs uses this exact pattern.
Lesson 3: Property Names That Don't Exist
Splash silently ignores unknown properties. These caused us real debugging time:
| Wrong | Correct | Notes |
|---|---|---|
| | TextInput password mode |
on LoadingSpinner | (not supported) | LoadingSpinner has no property |
| Works, but needs too | Backdrop alone doesn't make window transparent |
Lesson 4: Emoji Rendering
Makepad's text renderer supports some emoji but not all:
| Works | Doesn't Work |
|---|---|
| 🎙 🔍 🔄 | ✨ ⏳ |
If an emoji shows as a box or garbage character, try a different one. Stick to basic emoji from the BMP (Basic Multilingual Plane).
Lesson 5: width: Fit
+ Large border_radius
= Spiky Shape
width: Fitborder_radiusRoundedView with border_radius: 28.0 on a height: 56 capsule produces diamond-shaped ends instead of half-circles. The underlying sdf.box() formula breaks when radius >= min(w,h)/2.
Fix: Use a custom SDF capsule shader instead of
RoundedView:
View{ show_bg: true draw_bg +: { pixel: fn() { let r = self.rect_size.y * 0.5 let px = self.pos.x * self.rect_size.x let py = self.pos.y * self.rect_size.y let cx = clamp(px, r, max(r, self.rect_size.x - r)) let d = length(vec2(px - cx, py - self.rect_size.y * 0.5)) - r let alpha = 1.0 - smoothstep(-1.0, 1.0, d) return Pal.premul(vec4(0.1, 0.1, 0.18, alpha * 0.82)) } } }
Lesson 6: Multi-Window App — Window Visibility Control
Windows declared in
script_mod! auto-show on startup. Makepad WindowRef has no close()/open() method.
Working pattern: Declare windows at normal size. Use
configure_window() to bring to front when needed.
// Show: configure_window triggers makeKeyAndOrderFront on macOS let settings = self.ui.window(cx, ids!(settings_window)); settings.configure_window(cx, dvec2(480.0, 560.0), dvec2(500.0, 200.0), false, "Settings".into()); // Hide: resize to 1x1 (no close/minimize on WindowRef) let capsule = self.ui.window(cx, ids!(capsule_window)); capsule.resize(cx, dvec2(1.0, 1.0));
Note:
reposition(cx, dvec2(-9999, -9999)) does NOT reliably hide macOS floating panels.
Lesson 7: new_batch: true
and Widget Z-Order
new_batch: trueAdding a
LoadingSpinner (or any child widget with its own draw shader) inside a custom-shader View can cause bleed-through at the edges — the child widget draws outside the parent's SDF mask.
Fix: Either:
- Draw animation in the parent's own pixel shader (no child widget z-order issues)
- Use
+clip_x: true
on the parent (may not fully solve it)clip_y: true
Lesson 8: Continuous Redraw for Time-Based Shader Animation
self.draw_pass.time in a shader only advances when the widget is redrawn. Without explicit redraw, time freezes.
fn handle_next_frame(&mut self, cx: &mut Cx, _e: &NextFrameEvent) { if self.inner.state != STATE_IDLE { // Redraw the WINDOW (not just the view) to update draw_pass.time self.ui.widget(cx, ids!(my_window)).redraw(cx); self.inner.next_frame = cx.new_next_frame(); } }
Key: Redraw the
Window widget, not just the inner View. The draw pass time is per-window.
Lesson 9: #[rust]
Fields with Complex Types in Script-Derived Structs
#[rust]#[derive(Script, ScriptHook)] structs can only have #[rust] fields whose types implement Default. For complex non-Default types (channels, handles, etc.), wrap them in an Inner struct:
#[derive(Default)] struct Inner { timer: Timer, rx: Option<crossbeam_channel::Receiver<u64>>, handle: Option<SomeNonDefaultHandle>, } #[derive(Script, ScriptHook)] pub struct App { #[live] ui: WidgetRef, #[rust] inner: Inner, // Single Default-able wrapper }