makepad/splash.md
Kevin Boos 03705413e7
Constrain Splash external I/O to the host service bridge (#1243)
* Constrain Splash external I/O to the host service bridge

* Validate untrusted Splash source with the host I/O restriction

* Give strict Splash validation a disposable storage jail

* Close the remaining ways out of the Splash host I/O restriction

- Keep CachedWidget singletons per heap, and don't register CachedWidget
  or WindowMenu in restricted isolates.
- Don't emit Html/Markdown link URLs as actions from a restricted isolate,
  and ignore its menu bar updates.
- Suppress clipboard copy/cut hits for any restricted isolate, not only
  within Splash.
- Give script calls made while an isolate is installed the same budget.
- On web, let package resource fetches skip the guest I/O guard.
- Name the validation jail without the wall clock, and skip it on wasm.
- Make the host I/O tests fail when their guards are removed.
2026-09-25 00:56:09 +02:00

30 KiB
Raw Permalink Blame History

Splash DSL Guide

General Makepad Splash DSL patterns that apply to ANY app body.

Key Rules

  • let/fn declarations must be at the top, before any widget. The body starts with declarations, then the root widget.
  • Every container MUST have height: Fit — most common failure mode. Inside a fixed-height parent, height: Fill is fine.
  • Root container MUST use width: Fill — never a fixed pixel width. The app renders inside a parent container that provides the width.
  • ui object is built-in; do NOT declare it with :=
  • for loops render widgets at build time only — array changes do NOT re-render. Use set_text() for dynamic content.
  • Functions with for loops return empty strings when called from on_click — inline string building instead
  • as int type casting produces NaN — use string display + set_text() only
  • Colons inside string arguments work correctly — "Time: 2:30" is fine
  • Every TextInput must have a fixed numeric height (e.g. 34)
  • on_render works for dynamic lists. Store results in an array, render rows inside a ScrollYView{on_render: || {...}}, and call ui.<view>.render() after the array changes. Give the scroll view a fixed height (e.g. height: 360) — a Fill height inside a Fit parent collapses to zero.
  • A := id is only a field of its DIRECT parent. In Card{ a := Image{} inner := View{ b := Label{} } }, reach the label as Card{inner.b.text: "x"} — Card{b.text: ...} fails with "field b not found in type-check". Name every wrapper you need to address through.
  • AI Chat runsplash blocks run in a sandbox with net.http_request, http_resource(...), parse_json(), and url_encode() enabled. Use these for HTTP-backed mini apps.
  • Array / argument / object-body items may be separated by whitespace, newlines, OR commas — all work. Adjacent values like {a:1} {b:2} (or one per line) are TWO separate items. Two object literals next to each other are NEVER "object inherits from object" — to extend/merge an object use base += {field: val} or a named prototype Proto{...}, not bare {...}{...}. (So let days = [{...} {...} {...}] correctly yields a 3-element array; a stray earlier bug collapsed comma-less object arrays to length 1.)

Widget Availability

Available: View, RoundedView, SolidView, ScrollYView, Label, TextInput, LinkLabel, Button, ButtonFlat, ButtonFlatter, Slider, CheckBox, CheckBoxFlat, RadioButton, RadioButtonFlat, ToggleFlat, DropDown, TabBar, Tab, PopupMenu, ScrollBar, ScrollBars, LoadingSpinner, Hr, Vr, Icon, Image

NOT available (silently fail): Stack, Divider, ProgressBar, IconButton, ToggleButton, ListView, Grid, ColorPicker, ScrollPair

Wanted Not Available Use Instead
Divider line Divider Hr{height:1 width:Fill}
Progress bar ProgressBar Slider{value:0.65 is_read_only:true}
Tabbed UI TabBar/Tab ButtonFlat rows (TabBar renders zero-size)

Styling Gotchas

draw_bg.border_radius takes a float, not an Inset:

// ✅
draw_bg.border_radius: 16.0

// ❌ parse error — silently breaks layout
draw_bg.border_radius: Inset{top:0 bottom:16 left:0 right:0}

#x prefix for hex colors containing 'e': When a hex color contains the letter e adjacent to digits (like #1e1e2e), use #x to avoid parser ambiguity. Without #x, Makepad's parser may misinterpret digits following 'e' as an exponent:

#x2ecc71     // ✅ contains 'e' next to digits, use #x
#x1e1e2e     // ✅ contains 'e' next to digits, use #x
#ff4444      // ✅ no 'e' issue, plain # works
#00ff00      // ✅ no 'e' issue

Default text color is white: All text widgets (Label, Button, etc.) default to #fff. For light/white backgrounds, you MUST explicitly set draw_text.color to a dark color on every text element:

RoundedView{draw_bg.color:#f5f5f5 height:Fit
  Label{text:"Visible!" draw_text.color:#x222}
}

Label styling shorthand: Both syntaxes work:

Label{text:"Hello" color:#x2ecc71 font_size:16}              // bare props work
Label{text:"Hello" draw_text.color:#x2ecc71 draw_text.text_style.font_size:16}  // draw_text also works

new_batch: true for text visibility: Required on any container with show_bg: true that contains text children. Without it, text renders behind the background (invisible):

// ✅ Correct
RoundedView{width:Fill height:Fit new_batch:true show_bg:true draw_bg.color:#x334
  Label{text:"Visible" draw_text.color:#fff}
}
// ❌ Text may be invisible (draws behind bg)
RoundedView{width:Fill height:Fit show_bg:true draw_bg.color:#x334
  Label{text:"Invisible!" draw_text.color:#fff}
}

Widget Reliability Reference

Widget Capabilities Best For
ButtonFlat Click → variable write, set_text(), text() All interactive controls
Button Click → variable write, set_text(), text() Standard buttons
Label set_text() updates visible text, text() reads back Display values, status, dynamic list display
TextInput type_text fills first input, text() reads value, set_text() writes Text entry
Image src: http_resource(url), set_src(http_resource(url)), fit: ImageFit.CropToFill/Smallest Thumbnails and remote images
Hr Full-width line divider Visual separation
RoundedView Container with rounded corners App root, groups

Splash VM Variable Scope

let variables DO persist across click events in the same app session:

  • Counter: let count = 0; count = count + 1 correctly produces 1, 2, 3, 4 across consecutive clicks
  • Toggle: let toggled = false; toggled = !toggled persists true state across separate button clicks

However, widget checked state on RadioButton, ToggleFlat, CheckBox does NOT persist because internal post-processing discards the on_click scope context.

Widget Visual State Variable Persistence
RadioButton checked: true in widget tree ❌ Lost — internal post-processing discards on_click scope
ToggleFlat checked visual renders ❌ Same limitation
CheckBox / CheckBoxFlat checked: true in widget tree ❌ Same limitation

Use ButtonFlat with manual toggle for persistent boolean state:

let toggled = false
ButtonFlat{text:"Toggle" on_click:||{toggled = !toggled; ui.display.set_text("" + toggled)}}
ButtonFlat{text:"Show" on_click:||{ui.display.set_text("Current: " + toggled)}}

Patterns

Struct Arrays & Array Operations

The Splash VM supports arrays of structs with .push(), .remove(), .len(), and .retain(). Read fields via array[index].field, update with array[index] += {field: val}:

let items = [
    {text: "Task 1" tag: "work" done: false}
    {text: "Task 2" tag: "personal" done: false}
]
let max_items = 5

fn add_item(text){
    let clean = ("" + text).trim()
    if clean == "" { return }
    if items.len() >= max_items { return }
    items.push({text: clean tag: "" done: false})
    sync_all()
}

fn toggle_item(index){
    if index >= items.len() { return }
    items[index] += {done: !items[index].done}
    sync_all()
}

fn remove_item(index){
    if index >= items.len() { return }
    items.remove(index)
    sync_all()
}

fn clear_flagged(){
    items.retain(|it| !it.done)
    sync_all()
}

Component / Template Pattern

Define reusable templates with let and instantiate with property overrides:

let ItemRow = RoundedView{
    width: Fill height: Fit
    padding: Inset{top: 8 bottom: 8 left: 12 right: 12}
    flow: Right spacing: 10
    align: Align{y: 0.5}
    new_batch: true
    draw_bg.color: #x2a2a3a
    draw_bg.border_radius: 8.0
    label := Label{text: "item" width: Fill draw_text.color: #xddd}
    action := ButtonFlatter{text: "Do" width: 56 height: 28}
    remove := ButtonFlatter{text: "X" width: 56 height: 28}
}

row_0 := ItemRow{
    label.text: "First item"
    action.on_click: || do_something(0)
    remove.on_click: || remove_item(0)
}

Override syntax: <child-name>.<property>: <value> — every segment in the path must use :=.

Pre-allocated Fixed Slots

for loops render at build-time only — array changes don't add/remove widgets. Pre-allocate a fixed number of rows and update via sync functions:

let items = [{text: "Item 1"} {text: "Item 2"}]

fn sync_row_0(){
    if 0 < items.len() {
        ui.row_0.label.set_text(items[0].text)
    } else {
        ui.row_0.label.set_text("Empty slot")
    }
}
fn sync_rows(){
    sync_row_0()
    sync_row_1()
    sync_status()
}

Pre-allocate 5 rows for a 5-item max list. Call sync_rows() after every mutation.

Numeric State Pattern

let count = 0
RoundedView{width:Fill height:Fit flow:Down spacing:10 padding:16 new_batch:true
  display := Label{text:"0" draw_text.color:#x44cc88 draw_text.text_style.font_size:32}
  View{flow:Right spacing:12 align:Align{x:0.5 y:0.5}
    ButtonFlat{text:"-" on_click:||{count -= 1; ui.display.set_text(count + "")}}
    ButtonFlat{text:"Reset" on_click:||{count = 0; ui.display.set_text("0")}}
    ButtonFlat{text:"+" on_click:||{count += 1; ui.display.set_text(count + "")}}
  }
}

Use count + "" to convert numbers to strings.

Dynamic List Display

let task_count = 0
inp := TextInput{height:34}
lst := Label{text:"" font_size:14.0}
ButtonFlat{text:"Add" on_click:||{
  let t = ui.inp.text()
  if t != "" {
    task_count = task_count + 1
    let cur = ui.lst.text()
    if cur == " " { cur = "" }
    if cur != "" { cur = cur + "\n" }
    ui.lst.set_text(cur + task_count + ". " + t)
    ui.inp.set_text("")
  }
}}

TextInput with on_return

input := TextInput{
    width: Fill height: 34
    empty_text: "Enter something"
    on_return: |text| add_item(text)
}
Button{text: "Add" width: 64 height: 34 on_click: || add_item(ui.input.text())}

For image-search mini apps in AI Chat, use the built-in network sandbox. Fetch text/JSON with net.http_request, parse with .parse_json(), and display thumbnails with Image{src: http_resource(url)}. Render changing result arrays through ScrollYView.on_render and refresh with ui.results.render().

let image_results = []

let ResultCard = glass.Card{
    width: Fill height: Fit
    flow: Right spacing: 10 padding: 10
    thumb := Image{width: 92 height: 72 fit: ImageFit.CropToFill}
    // Name every wrapper you need to reach through: a `:=` id is only a field of its
    // DIRECT parent. Under an unnamed `View{...}`, `title`/`source` would be unreachable
    // from `ResultCard{...}` ("field title not found in type-check").
    info := View{width: Fill height: Fit flow: Down spacing: 3
        title := glass.Body{text: "" width: Fill}
        source := glass.Caption{text: ""}
    }
}

fn fetch(url, extra_headers){
    let p = promise()
    let h = {"User-Agent": "Mozilla/5.0"}
    if extra_headers != nil { for k, v in extra_headers { h[k] = v } }
    net.http_request(net.HttpRequest{url: url method: net.HttpMethod.GET headers: h}) do net.HttpEvents{
        on_response: |res| p.resolve(res)
        on_error: |_err| p.resolve(nil)
    }
    p
}

fn search_images(query){
    let clean = ("" + query).trim()
    if clean == "" { return }
    ui.status.set_text("Searching...")
    let q = clean.url_encode()
    let page = fetch("https://duckduckgo.com/?q=" + q + "&iax=images&ia=images", nil).await()
    if page == nil { ui.status.set_text("Search failed"); return }
    let vqd = ""
    let parts = page.body.to_string().split("vqd=\"")
    if parts.len() > 1 { vqd = parts[1].split("\"")[0] }
    if vqd == "" { ui.status.set_text("No image token"); return }
    let res = fetch("https://duckduckgo.com/i.js?l=us-en&o=json&q=" + q + "&vqd=" + vqd + "&f=,,,,,&p=1", {"Referer": "https://duckduckgo.com/"}).await()
    if res == nil { ui.status.set_text("No response"); return }
    let data = res.body.to_string().parse_json()
    image_results.clear()
    if data != nil && data.results != nil {
        for img in data.results {
            image_results.push({title: img.title source: img.source thumbnail: img.thumbnail})
        }
    }
    ui.status.set_text("" + image_results.len() + " images")
    ui.results.render()
}

View{width: Fill height: Fit flow: Down spacing: 12 padding: 16
    glass.H1{text: "Picture Search"}
    glass.Card{width: Fill height: Fit flow: Right spacing: 8
        query := glass.TextInput{width: Fill height: 38 empty_text: "Search images..." on_return: |text| search_images(text)}
        glass.GlassButtonProminent{text: "Search" height: 38 on_click: || search_images(ui.query.text())}
    }
    status := glass.Caption{text: "Type a query"}
    results := ScrollYView{width: Fill height: 360 flow: Down spacing: 8 on_render: || {
        if image_results.len() == 0 {
            glass.Card{glass.Body{text: "Results will appear here."}}
        }
        else for item in image_results {
            ResultCard{thumb.src: http_resource(item.thumbnail) info.title.text: item.title info.source.text: item.source}
        }
    }}
}

Sequential Digit Input

Perform arithmetic by accumulating digits:

let a = 0
ButtonFlat{text:"7" on_click:||{a = a*10+7; ui.display.set_text("" + a)}}

Naming Children: := vs :

Use := for addressable children, : for static children:

label := Label{text:"default"}    // ✅ addressable via ui.label, overridable
label: Label{text:"default"}     // ❌ static — NOT addressable

Every path segment in an override must use :=:

// ✅ Correct
let Item = View{flow:Right
  texts := View{flow:Down
    label := Label{text:"default"}
  }
}
Item{texts.label.text:"new text"}  // works!

// ❌ Wrong — anonymous parent blocks override
let Item = View{flow:Right
  View{flow:Down
    label := Label{text:"default"}  // UNREACHABLE
  }
}
Item{label.text:"new text"}  // silent failure

Styling Reference

Property Example Effect
draw_bg.color #x1e1e2e Background color (hex)
draw_bg.border_radius 10.0 Rounded corners
draw_text.color #xddd Text color
draw_text.text_style.font_size 14 Font size (float)
padding Inset{top:8 bottom:8 left:12 right:12} Inner padding
spacing 10 Gap between children in flow
align Align{x:0.5 y:0.5} Center alignment
new_batch true Required for text visibility on show_bg:true containers
empty_text "Type here..." Placeholder for TextInput

Not in Build

Widget Behavior
TabBar / Tab width=0, height=0 — no visible output

Liquid Glass widgets (glass.*)

Makepad ships an Apple-style "Liquid Glass" widget kit under the glass. namespace. It is already in scope inside every runsplash block (no import needed). All names below are addressed as glass.<Name>.

⛔ HARD REQUIREMENT — use glass widgets

The chat this renders in is a glass-styled app. Unless the user explicitly asks for a plain/non-glass look, you MUST build every app out of glass.* widgets on a detailed backdrop:

  • Surfaces/cards → glass.Card / glass.Panel / glass.Group (NOT plain View/RoundedView with a flat fill).
  • Buttons → glass.GlassButton / glass.GlassButtonProminent — these are the REAL liquid-glass buttons that lens and refract the backdrop (the proper "gauss glass" look). They are fully clickable (on_click works). Prefer these for every button. glass.Button / glass.Chip are flatter, non-refracting fallbacks — only use them if you specifically want a flat chip.
  • Toggles → glass.GlassRadio (a liquid-glass switch, on_click works).
  • Text → glass.H1 / glass.H2 / glass.Body / glass.Caption / glass.OptionLabel.
  • Inputs/toggles/sliders → glass.TextInput / glass.SearchField / glass.Toggle / glass.Slider / glass.GlassRadio.
  • And the app MUST open with a detailed colourful backdrop (see the golden rule below) so the glass is actually visible.

An app that uses plain Button/View/Label on a flat dark fill is WRONG for this chat — it will look like a generic dark app with no glass.

THE GOLDEN RULE: be TRANSPARENT — the chat already paints the backdrop

The chat this renders in already paints a rich, detailed, colourful backdrop behind your app. Your job is to float transparent glass on top of it so the glass refracts that backdrop. Glass lenses whatever is painted behind it; if you paint an opaque background, the glass has nothing to bend and the whole thing looks like a flat dark app.

Therefore your app MUST be transparent:

  • NEVER put show_bg: true with an opaque draw_bg.color on your root or on any container. No draw_bg.color: #x05070e, no opaque RoundedView, no full-screen background layer, no Svg backdrop. The host backdrop is already there — let it show through.
  • Build surfaces only from glass.* widgets (glass.Card, glass.Panel, glass.Group) — these are already translucent and refract the backdrop. Plain View/RoundedView with a fill will hide it.
  • The root is just a transparent layout View (flow: Down, spacing, padding). Nothing else.
View{
  width: Fill height: Fit
  flow: Down spacing: 14 padding: 20
  // No background here — transparent on purpose so the chat backdrop shows through.
  glass.H1{text: "Glass Kit"}
  glass.Body{text: "A liquid-glass showcase."}
  glass.Card{
    glass.H2{text: "Liquid Glass"}
    glass.Body{text: "Floats above the chat backdrop, refracting it."}
  }
  View{width:Fill height:Fit flow:Right spacing:10
    glass.GlassButtonProminent{text:"Continue"}
    glass.GlassButton{text:"Cancel"}
  }
}

If you catch yourself writing draw_bg.color on a container, delete it and use a glass.Card/glass.Panel instead.

Surfaces / containers

Widget Use for Notes
glass.Panel Main glass card / sheet Fill width, Fit height, flow Down, padding 16. Also aliased GlassPanel.
glass.Card Content card More tint than Panel — good default card.
glass.Group Grouped controls Tighter padding than Panel.
glass.ClearPanel Clearer floating surface Stronger lensing, less tint.
glass.NavBar Top/bottom bar height 58, flow Right.
glass.TabBar Floating tab bar height 66, flow Right. Put glass.Caption/Label children in it. (This is the glass TabBar — NOT the broken core TabBar.)
glass.List List container translucent panel, flow Down.
glass.ListRow One row in a list height 54, flow Right, vertically centered.
glass.Badge Small status pill green-tinted capsule.

All surfaces already set width/height/flow/padding, so you can drop children straight in. They do NOT need new_batch (they manage their own batching), but the outer dark root DOES (new_batch: true + show_bg: true).

Every glass surface already carries a built-in soft drop shadow (kept deliberately subtle by default). Do not add your own large shadows, and don't crank shadow_radius/shadow_offset — an oversized shadow reads as "fat" and gets clipped by tight parent bounds. If you ever need to tune it, keep it small (e.g. draw_bg +: { shadow_radius: 9.0 shadow_offset: vec2(0.0, 3.0) }).

Buttons (reliable — use these for clicks)

glass.Button, glass.ProminentButton, glass.IconButton, and glass.Chip are flat SDF glass buttons built on ButtonFlat. They are the reliable interactive choice and support on_click, set_text(), text() exactly like Button:

Row{flow:Right spacing:10
  glass.GlassButtonProminent{text:"Add" on_click:|| add_item(ui.inp.text())}
  glass.GlassButton{text:"Clear" on_click:|| clear_all()}
  glass.Chip{text:"All"}
}

Premium lensing controls (decorative-first)

These are custom widgets that draw a real refracting lens over the scene. They look spectacular but are best used as showcase / display controls — prefer the flat glass.Button family when you need a reliable on_click handler in a generated app.

Widget What it is Default size
glass.GlassButton Refracting pill button with a label Fit × 44, has text:
glass.GlassButtonProminent Blue-tinted refracting button same
glass.GlassRadio Liquid toggle switch (slides + "gloops") 70 × 34
glass.GlassSlider Refracting slider knob Fill × 32, value: 0.4
ToggleRow{flow:Right spacing:16 align:Align{y:0.5}
  glass.GlassRadio{}
  glass.OptionLabel{text:"Wi-Fi"}
}
glass.GlassButton{text:"Continue"}

Lensing widgets sample a blurred snapshot of the window. Inside the chat they fall back to a solid frosted color when no snapshot is available, so they still render — just less dramatically than in a full-screen app like example-glass.

Inputs

Widget Use for
glass.TextInput Glass text field (height 38)
glass.SearchField Same, "Search" placeholder
glass.Slider Labeled glass slider (height 40)
glass.Toggle Glass on/off toggle
glass.RadioButton Glass pill radio

Typography (pre-styled white-on-dark labels)

Use these instead of restyling Label by hand — they already set the right color and weight for dark glass UIs:

glass.H1 (30), glass.H2 (18), glass.OptionLabel (16), glass.Body (13, Fill width), glass.ButtonLabel (13), glass.Caption (11, muted blue — good for SECTION HEADERS).

Full minimal glass app skeleton

fn noop(){}

RoundedView{
  width: Fill height: Fit
  flow: Down spacing: 14 padding: 20
  new_batch: true show_bg: true
  draw_bg.color: #x05070e
  draw_bg.border_radius: 18.0

  glass.H1{text: "Settings"}
  glass.Caption{text: "NETWORK"}
  glass.Card{
    View{width:Fill height:Fit flow:Right spacing:16 align:Align{y:0.5}
      glass.GlassRadio{}
      glass.OptionLabel{text:"Wi-Fi"}
    }
  }
  glass.Caption{text: "ACTIONS"}
  Row{flow:Right spacing:10
    glass.GlassButtonProminent{text:"Save" on_click:|| noop()}
    glass.GlassButton{text:"Cancel" on_click:|| noop()}
  }
}

Interactivity that ACTUALLY works (read this for calculators / counters / forms)

The ui object is only injected into the scope of an on_click / on_return / on_change closure. It is NOT visible inside a separately-declared fn. So:

  • ✅ Do all ui.<id>.set_text(...) / ui.<id>.text() calls inline inside the closure.
  • ❌ Do NOT call a helper fn that itself references ui — it will fail with "variable ui not found".
  • Shared state lives in top-level let variables (these persist across clicks). Mutate them inline.
  • Arithmetic must stay numeric — never store a number as a string and add it (string + concatenates: "5"+"3"=="53"). Build numbers with acc = acc*10 + digit. Display with "" + number. Never use as int (produces NaN).
  • The widget you update with set_text must be reachable as ui.<id> — give it a := id and keep it directly addressable.

Working glass calculator (numeric, inline ui, copy this shape)

let acc = 0.0       // number currently being entered
let stored = 0.0    // left-hand operand
let op = ""

View{ width: Fill height: Fit flow: Down spacing: 12 padding: 18
  // Transparent root — the chat backdrop shows through and the glass refracts it.
  glass.Card{ display := glass.H1{text:"0" width: Fill align: Align{x:1.0}} }
    View{width:Fill height:Fit flow:Right spacing:8
      glass.GlassButton{text:"7" width:Fill height:52 on_click:|| { acc = acc*10.0 + 7.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"8" width:Fill height:52 on_click:|| { acc = acc*10.0 + 8.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"9" width:Fill height:52 on_click:|| { acc = acc*10.0 + 9.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"+" width:Fill height:52 on_click:|| { stored = acc; acc = 0.0; op = "+" }}
    }
    View{width:Fill height:Fit flow:Right spacing:8
      glass.GlassButton{text:"4" width:Fill height:52 on_click:|| { acc = acc*10.0 + 4.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"5" width:Fill height:52 on_click:|| { acc = acc*10.0 + 5.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"6" width:Fill height:52 on_click:|| { acc = acc*10.0 + 6.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"-" width:Fill height:52 on_click:|| { stored = acc; acc = 0.0; op = "-" }}
    }
    View{width:Fill height:Fit flow:Right spacing:8
      glass.GlassButton{text:"1" width:Fill height:52 on_click:|| { acc = acc*10.0 + 1.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"2" width:Fill height:52 on_click:|| { acc = acc*10.0 + 2.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"3" width:Fill height:52 on_click:|| { acc = acc*10.0 + 3.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"*" width:Fill height:52 on_click:|| { stored = acc; acc = 0.0; op = "*" }}
    }
    View{width:Fill height:Fit flow:Right spacing:8
      glass.GlassButton{text:"0" width:Fill height:52 on_click:|| { acc = acc*10.0; ui.display.set_text("" + acc) }}
      glass.GlassButton{text:"C" width:Fill height:52 on_click:|| { acc = 0.0; stored = 0.0; op = ""; ui.display.set_text("0") }}
      glass.GlassButtonProminent{text:"=" width:Fill height:52 on_click:|| {
        let r = stored
        if op == "+" { r = stored + acc }
        if op == "-" { r = stored - acc }
        if op == "*" { r = stored * acc }
        acc = r; op = ""
        ui.display.set_text("" + r)
      }}
    }
  }

Every digit/operator handler is inline (no helper fn), so ui.display is in scope, and all arithmetic is numeric. This pattern computes correctly AND looks glassy.

Glass gotchas

  • Transparent root is mandatory — the chat paints the backdrop; if you paint your own opaque background the glass refracts nothing and looks like a flat dark app. No draw_bg.color / show_bg on your root or containers.
  • For buttons use the lensing glass.GlassButton / glass.GlassButtonProminent (they refract the backdrop — the real gauss-glass look — and are fully clickable). For toggles use glass.GlassRadio. The flat glass.Button/glass.Chip don't refract; only use them when you explicitly want a flat chip.
  • Don't restyle glass surface colors — they're tuned. Just set layout (width/height/spacing/padding) and drop content in.
  • Hex colors with e next to a digit still need the #x prefix (e.g. #x05070e).

Embedding untrusted mini-apps with host-mediated I/O

A host that enforces per-request permissions should call Splash::set_host_io_only(true) before the first set_text. The selection is host-owned, cannot be relaxed for that Splash, and is inherited by nested Splashes. CxSplashVmExt::alloc_splash_vm_with_host_io() provides the same mode for hosts allocating isolates directly, which must dispatch and draw the guest's widgets inside with_isolate, as Splash does. allow_net: true cannot override it.

In this mode scripts use the existing host.request bridge for external I/O:

host.request("network.http", {url: "https://example.com/data"}, fn(result) {
    if result.is_ok { ui.output.set_text(result.data.body) }
    else { ui.output.set_text(result.error) }
})

network.http is an example host-defined service, not a built-in transport. The embedding host drains take_splash_host_requests(), authorizes the request using its trusted app_tag/heap_key, and responds through splash_host_respond(). An unanswered request performs no I/O. The host owns URL/method/header/body validation, redirect authorization before each hop, credential isolation, DNS/private-network checks, response limits, cancellation, and rechecking policy immediately before transmission and response delivery. Granting one request must never enable a native networking runtime for the guest.

Direct HTTP, WebSockets, raw sockets, and listeners are rejected. Resource constructors are checked at the native entrypoint, including aliases retained in the widget prelude. Native browser launch, media playback, clipboard export, file pickers, drag export, and menu bar changes are suppressed while the isolate is installed, and Html/Markdown links don't emit their URLs as actions. Browser, MapView, ScreenCap, Window, WindowMenu, and CachedWidget constructors are not registered in the restricted isolate. Script callbacks run within the same per-call budget as other entries into an isolate. Splash installs its own context while dispatching/drawing children, so native widget behavior cannot accidentally borrow host authority. Per-Splash stylesheet re-evaluation is disabled in this mode; restricted isolates retain the trusted boot theme chosen with set_splash_theme.

The host may still explicitly supply a private jailed filesystem through set_sandbox_dir. For information-flow enforcement, either label all data in that persistent jail across instances and future runs, or leave it unset and provide labeled storage through the host bridge. Host-supplied native widgets, handles, services, and callbacks are capabilities too: register only audited ones and propagate source restrictions through their results. Network denial alone is not an information-flow policy; the host must track private sources and authorize every outgoing sink, including model inference and IPC.

This is an API-level confinement boundary for script code, not process isolation: it does not address native memory-safety bugs, resource exhaustion, or covert channels such as rendering and timing. Default Splash behavior remains unchanged unless the host selects this mode.