wdoc · reference

Wireframes

13 min read · 2026-08-20 · wcl 0.33.2-alpha

A wireframe is a mock-up of an interface: a window, a phone screen, a row of buttons. wdoc draws one from eighteen wf_* blocks. There are four frames, four layout containers, seven controls, and a three-block node graph. The result is static SVG. It renders in the book, in a PDF and in a slide deck, and it costs you no screenshot to keep up to date.

The one fact to carry through the chapter: a widget is a diagram shape, not a page block. Every wf_* widget extends SvgBlock, so it lives inside a diagram, sits at an x / y, and connects to other shapes with edges. It never sits directly in a page body.

Every example below was rendered before it was written down. Type them and compare.

One screen, start to finish

Save this as mock.wcl. It is a complete document, and it holds the schema, the site, the page and the mock-up:

mock.wclwcl
# mock.wcl — one screen, mocked up.
import <wdoc.wcl>

site mock { title = "Mock-ups" }

page checkout {
  title = "Checkout"

  h1 "Checkout"

  diagram {
    width = 340  height = 260

    wf_window "Checkout" {
      wf_panel { title = "Payment"
        wf_input "Card number" { value = "4242 4242 4242 4242" }
        wf_row {
          wf_input "MM / YY"
          wf_input "CVC"
        }
      }
      wf_checkbox "Save this card" { checked = true }
      wf_row {
        wf_button "Cancel"
        wf_button "Pay"
      }
    }
  }
}
console
$ wcl wdoc build mock.wcl --out _site
wrote 1 page

The page holds one diagram. The diagram holds one wf_window. Everything else nests inside the window, and nothing carries a size or a coordinate. Here is what it draws:

CheckoutPayment4242 4242 4242 4242MM / YYCVCSave this cardCancelPay

Read the structure from the inside out. Two wf_inputs sit in a wf_row, so they lay out side by side. The row and one more input sit in a wf_panel, which draws a bordered group with a caption. The panel, a checkbox and a button row sit in a wf_window, which draws the chrome and stacks its children. The window sits in the diagram, which is the only block on the page.

A widget is a diagram shape

wf_button is not a content block. Add one to the page body of mock.wcl, beside the h1, and the schema refuses it: a page declares @children(ContentBlock), and a widget is an SvgBlock.

console
$ wcl check mock.wcl
wcl::eval::schema_violation

  × block kind 'wf_button' is not allowed inside 'page'

mock.wcl: 1 schema violation

The diagram is what admits it, and the same containment gives you the rest of the diagram canvas for free. A widget takes an id, so an edge can name it. It takes x and y, so you can place it. It may stand beside a rect, a card or a flowchart shape in one drawing. The diagram canvas covers the canvas itself, and Connections and routing covers the edges.

Preview

Open settingsSettingsDark modeClose
Open settingsSettingsDark modeClose

Example

diagram {
  width = 430
  height = 140

  wf_button "Open settings" {
    id = launch
    x = 10.0
    y = 60.0
  }
  wf_window "Settings" {
    id = win
    x = 160.0
    y = 10.0
    wf_checkbox "Dark mode" {
      checked = true
    }
    wf_button "Close"
  }
  launch -> win
}

An edge leaves and arrives at the midpoint of a side. Narrow the choice with connect_points, which takes the four AnchorSide symbols: :north, :east, :south and :west. A widget with no connect_points offers all four.

You may also let the diagram place the widgets. Under layout = :layered, :force, :radial or :grid the solver reads each widget's measured size and assigns the coordinates. Drop x and y and write only the edges:

Preview

Sign inInbox3 unread messages
Sign inInbox3 unread messages

Example

diagram {
  width = 390
  height = 100
  layout = :layered
  direction = :left_to_right

  wf_button "Sign in" {
    id = a
  }
  wf_window "Inbox" {
    id = b
    wf_label "3 unread messages"
  }
  a -> b
}

The canvas needs a size

width and height are fields of the diagram, and a wireframe diagram must set both. They are the size the renderer draws the <svg> element at. Leave them out and the element is width="0" height="0": the build succeeds, wcl check prints OK, and the mock-up is invisible.

Nothing warns you about a sizeless canvas

A missing required field is not something wcl check reports today, so diagram { wf_button "Lost" } is a clean build and a blank page. If a wireframe does not appear, check the diagram's width and height first.

The two numbers are the drawn size, not a crop. The renderer fits the viewBox to the measured content, with 10px of padding. A widget larger than the canvas therefore scales down rather than clipping. Get the numbers roughly right and the mock-up keeps its natural scale. A 640-wide browser frame in a 200-wide canvas is legible, but small.

Measured sizing

A widget sizes itself from its content. A button is as wide as its caption plus padding. A window is as wide as its widest child, subject to a 256px minimum, and as tall as its stacked children plus the titlebar. Nothing cascades down from the diagram, and nothing inherits a size from a parent.

That is why width and height are declared advisory on the shared Widget interface. For every widget except the three device frames, the renderer reads them for the anchor arithmetic and ignores them for drawing. width = 400.0 on a wf_button does not widen the button.

The renderer measures text with an average glyph advance rather than a font metric, because the build has no font system. The estimate rounds up, so a box is occasionally a little roomy and never clips its label.

The device frames are the exception, and they are the exception on purpose: a phone mock-up is only useful at phone proportions.

FrameDefault sizeWhat width / height do
wf_browser640 × 440Each pins its own axis, and the unset axis keeps the default
wf_phone280 × 580As above. orientation = :landscape swaps the two defaults
wf_tablet480 × 640As above

A device frame also grows past its default height when the content would overflow. A long screen gets a long phone, not a clipped one.

Nesting

Every container widget declares @children(Widget), so any widget may go inside any container, to any depth. A window holds a panel, the panel holds a row, the row holds three buttons.

The container owns the layout of its children. A child's x and y are ignored: only the outermost widget's placement puts the group on the canvas. Both buttons below carry coordinates, and the panel lays out both of them anyway:

Preview

Coordinates ignoredAB
Coordinates ignoredAB

Example

diagram {
  width = 180
  height = 140
  wf_panel {
    title = "Coordinates ignored"
    wf_button "A" {
      x = 200.0
      y = 200.0
    }
    wf_button "B"
  }
}

Only the built-in widgets nest

A container lays out the sixteen built-in widgets and drops anything else. A widget of your own, say a @block("wf_gauge") type ... extends Widget with its own lower, renders perfectly well as a top-level shape in the diagram, and renders as nothing inside a wf_window. The container drops it silently and reports nothing. See Writing your own widget.

Frames

A frame is the outer chrome of a mock-up: the thing that says "this is a desktop window" or "this is a phone". All four stack their children vertically with a 10px gap.

wf_window

A desktop window: a titlebar over a padded body. The inline label is the title. The titlebar carries two dots and a close glyph unless you set controls = false.

Preview

PreferencesCheck for updatesSend crash reportsRevertApply
PreferencesCheck for updatesSend crash reportsRevertApply

Example

diagram {
  width = 280
  height = 170
  wf_window "Preferences" {
    wf_checkbox "Check for updates" {
      checked = true
    }
    wf_checkbox "Send crash reports"
    wf_row {
      wf_button "Revert"
      wf_button "Apply"
    }
  }
}
FieldTypeMeaning
titleutf8The titlebar caption. Written as the inline label.
controlsbool?Draw the titlebar dots and close glyph. Default true.
childrenlist<Widget>The body, stacked top to bottom.

wf_browser

A web-browser frame: three dots and an address bar over a content area. The inline label is the URL. Use it when the mock-up is a web page rather than an application window. A wf_window inside a wf_browser then reads as a modal over that page.

The frame below pins both axes, because the default 640 × 440 is wider than this column. Leave width and height out for the default.

Preview

app.example.com/reportsFiltersLast 30 daysSearchApplyInclude archived
app.example.com/reportsFiltersLast 30 daysSearchApplyInclude archived

Example

diagram {
  width = 400
  height = 280
  wf_browser "app.example.com/reports" {
    width = 380.0
    height = 260.0
    wf_panel {
      title = "Filters"
      wf_dropdown "Last 30 days"
      wf_row {
        wf_input "Search"
        wf_button "Apply"
      }
    }
    wf_toggle "Include archived" {
      on = true
    }
  }
}
FieldTypeMeaning
urlutf8The address-bar text. Written as the inline label.
childrenlist<Widget>The content area, stacked top to bottom.

wf_phone

A phone shell: a bezel around a screen, a status bar with a notch and a battery, and a home-indicator pill at the foot. The inline label is optional and becomes the status-bar caption, the clock by convention.

Preview

9:41Accountada@example.com········Remember meSign in
9:41Accountada@example.com········Remember meSign in

Example

diagram {
  width = 300
  height = 600
  wf_phone "9:41" {
    wf_panel {
      title = "Account"
      wf_input "Email" {
        value = "ada@example.com"
      }
      wf_input "Password" {
        value = "········"
      }
    }
    wf_checkbox "Remember me" {
      checked = true
    }
    wf_button "Sign in"
  }
}
FieldTypeMeaning
titleutf8?Status-bar caption. Written as the inline label.
orientationsymbol?:portrait (default) or :landscape, which swaps the frame's two axes.
childrenlist<Widget>The screen, stacked top to bottom.

wf_tablet

The same chrome on a larger, squarer frame, with a camera dot in place of the notch. Its fields are wf_phone's. Landscape suits a two-pane layout, and, as above, the example below pins the frame smaller than its 640 × 480 landscape default:

Preview

LibraryRecentFavouritesDownloadsReaderSelect an item.
LibraryRecentFavouritesDownloadsReaderSelect an item.

Example

diagram {
  width = 420
  height = 300
  wf_tablet {
    orientation = :landscape
    width = 400.0
    height = 280.0
    wf_row {
      wf_panel {
        title = "Library"
        wf_label "Recent"
        wf_label "Favourites"
        wf_label "Downloads"
      }
      wf_panel {
        title = "Reader"
        wf_label "Select an item."
      }
    }
  }
}

Layout

Four containers arrange what a frame holds. wf_panel also draws something, a border and a caption. The other three draw nothing at all: they are pure arrangement.

wf_panel

A bordered group with an optional caption. Use it to say that a set of controls belongs together. Its title is an ordinary field, not an inline label. Write wf_panel { title = "Network" }, not wf_panel "Network". That is the one naming inconsistency in the family, and it is worth remembering.

Preview

NetworkWi-FiBluetooth
NetworkWi-FiBluetooth

Example

diagram {
  width = 150
  height = 120
  wf_panel {
    title = "Network"
    wf_toggle "Wi-Fi" {
      on = true
    }
    wf_toggle "Bluetooth"
  }
}
FieldTypeMeaning
titleutf8?Caption above the group. A plain field.
childrenlist<Widget>The group's contents, stacked top to bottom.

wf_row

Lays its children out left to right, with a 13px gap, each centred on the row's vertical middle. It has no fields of its own. A row is what you reach for when a frame's default vertical stack is wrong: a button bar, a pair of fields, a toolbar.

Preview

BackNextFinish
BackNextFinish

Example

diagram {
  width = 210
  height = 50
  wf_row {
    wf_button "Back"
    wf_button "Next"
    wf_button "Finish"
  }
}

wf_column

Stacks its children top to bottom with a 10px gap, the same flow a window or panel body already uses. It has no fields of its own, and it earns its place inside a wf_row or a wf_grid, where the flow would otherwise be horizontal:

Preview

First nameAdaSurnameLovelace
First nameAdaSurnameLovelace

Example

diagram {
  width = 300
  height = 80
  wf_row {
    wf_column {
      wf_label "First name"
      wf_input "Ada"
    }
    wf_column {
      wf_label "Surname"
      wf_input "Lovelace"
    }
  }
}

wf_grid

Flows its children across a fixed number of equal-width columns. columns defaults to 2. Every column is as wide as the widest child in the whole grid, and each row is as tall as its own tallest child.

Preview

12345
12345

Example

diagram {
  width = 140
  height = 90
  wf_grid {
    columns = 3
    wf_button "1"
    wf_button "2"
    wf_button "3"
    wf_button "4"
    wf_button "5"
  }
}

An empty container keeps its footprint

A wf_row, wf_column or wf_grid with no children does not collapse to nothing. Each measures as placeholder cells of 72 × 34: two side by side for a row, two stacked for a column, and two rows of columns cells for a grid. The editor can then see the container, select it, and drop a widget into it. A published build shows the blank space.

Controls

Seven leaf widgets. None of them takes children, every one of them takes the shared fields, and each carries its text as an inline label. Here they are together:

Preview

Every controlA plain labelA placeholderA valueA chosen optionA checkboxA radioA toggleA button
Every controlA plain labelA placeholderA valueA chosen optionA checkboxA radioA toggleA button

Example

diagram {
  width = 190
  height = 330
  wf_panel {
    title = "Every control"
    wf_label "A plain label"
    wf_input "A placeholder"
    wf_input "Filled" {
      value = "A value"
    }
    wf_dropdown "A chosen option"
    wf_checkbox "A checkbox" {
      checked = true
    }
    wf_radio "A radio" {
      selected = true
    }
    wf_toggle "A toggle" {
      on = true
    }
    wf_button "A button"
  }
}

disabled = true dims any of them, at 45% opacity. The flag is on the shared interface, so a container takes it too, and a dimmed wf_panel dims everything inside it.

Preview

EnabledDisabled
EnabledDisabled

Example

diagram {
  width = 180
  height = 50
  wf_row {
    wf_button "Enabled"
    wf_button "Disabled" {
      disabled = true
    }
  }
}

wf_label

One line of text, nothing around it. The inline label is the text.

wf_button

A rounded box with a centred caption. The inline label is the caption.

FieldTypeMeaning
textutf8The caption. Written as the inline label.
iconutf8?A leading glyph as pack.name. Accepted and not drawn, as described below.

The icon field draws nothing today

wf_button declares an icon field, and the renderer never reads it. A glyph would need the icon sprite. The sprite does not survive the baked-colour path that makes a wireframe render identically in HTML and PDF, so the caption is centred instead. wf_button "Save" { icon = "lucide.check" } builds without complaint and renders as a plain Save button.

wf_input

A text field. With no value, the inline label renders as a greyed italic placeholder. With a value, that value renders as solid text and the placeholder is not drawn. Either way the field is at least 130px wide.

Preview

Search projectsplatform
Search projectsplatform

Example

diagram {
  width = 150
  height = 90
  wf_column {
    wf_input "Search projects"
    wf_input "Owner" {
      value = "platform"
    }
  }
}
FieldTypeMeaning
placeholderutf8Greyed prompt shown when empty. Written as the inline label.
valueutf8?A filled value. Replaces the placeholder.

wf_dropdown

A select field: the chosen option, with a chevron on the right. The inline label is the chosen option. There is no list of alternatives. A wireframe shows the closed state.

wf_checkbox

A square box with a caption. checked = true fills the box with the accent colour and draws a tick. The inline label is the caption.

wf_radio

A round dot with a caption. selected = true fills it. Nothing groups radios or enforces that one is chosen. Lay the group out yourself and mark the active one.

Preview

DarkLightMatch the system
DarkLightMatch the system

Example

diagram {
  width = 160
  height = 100
  wf_column {
    wf_radio "Dark" {
      selected = true
    }
    wf_radio "Light"
    wf_radio "Match the system"
  }
}

wf_toggle

A sliding switch. on = true slides the knob across and fills the track with the accent colour. The inline label is optional here, unlike every other control: wf_toggle { on = true } is a legal bare switch.

FieldTypeMeaning
labelutf8?Optional trailing caption. Written as the inline label.
onbool?Switch state. true slides the knob across.

Node graphs

A wf_node_graph mocks up a node editor: a shader graph, a blueprint, a dataflow pipeline. It is one widget, not a container of widgets. Its children are wf_node and wf_link blocks, and those are not shapes at all. They extend nothing, they render only through their graph, and they are illegal anywhere else.

The graph lays itself out. The same layered solver a layout = :layered diagram uses ranks the nodes by the links between them. The links then route orthogonally around the boxes they do not connect.

Preview

TextureRGBAlphaFresnelFactorMultiplyABResultOutputColor
TextureRGBAlphaFresnelFactorMultiplyABResultOutputColor

Example

diagram {
  width = 440
  height = 220
  wf_node_graph {
    wf_node "Texture" {
      id = tex
      outputs = ["RGB", "Alpha"]
    }
    wf_node "Fresnel" {
      id = fres
      outputs = ["Factor"]
    }
    wf_node "Multiply" {
      id = mul
      inputs = ["A", "B"]
      outputs = ["Result"]
    }
    wf_node "Output" {
      id = out
      inputs = ["Color"]
    }
    wf_link "tex.RGB" {
      to = "mul.A"
    }
    wf_link "fres.Factor" {
      to = "mul.B"
    }
    wf_link "mul.Result" {
      to = "out.Color"
    }
  }
}

wf_node_graph

FieldTypeMeaning
directionsymbol?:left_to_right (the default) or :top_to_bottom.
nodeslist<WfNode>The boxes. Written as wf_node children.
linkslist<WfLink>The wires. Written as wf_link children.

A node graph flows left to right, while a layout = :layered diagram flows top to bottom. The two defaults differ because the two things differ. You read a node editor across. You read a flowchart down.

wf_node

One box: a title band over a body of ports. inputs label the ports down the left edge, and outputs label the ports down the right edge. Give a node an x or a y to pin it, and the solver places the rest around it.

FieldTypeMeaning
titleutf8The caption. Written as the inline label.
ididentifier?The name a link addresses. Without one, no link can reach the node.
inputslist<utf8>?Port labels down the left edge.
outputslist<utf8>?Port labels down the right edge.
x / yf64?Pin the node instead of letting the solver place it.
classlist<utf8>?Classes whose fill / stroke repaint the box.

One wire, from an output port to an input port. An endpoint is "node" or "node.port". The node part is the target's id. The port part matches a port label, and case does not matter. A bare "node" takes its first port, and a node with exactly one port on the relevant side accepts any port name.

FieldTypeMeaning
fromutf8Source endpoint, an output port. Written as the inline label.
toutf8Destination endpoint, an input port.
labelutf8?Optional caption at the wire's midpoint.

An unresolvable link disappears

The graph drops a link when its endpoint names an unknown node, or a port that a multi-port node does not have. The build says nothing. The usual cause is a missing id: nothing can address a wf_node without one, so every link touching it silently vanishes while the node itself still draws.

The shared fields

The sixteen widgets all extend the Widget interface, so every field below is legal on every one of them. wf_node and wf_link are the two blocks in the family that do not. They are graph parts rather than shapes, and they carry only the fields Node graphs lists. The standard library repeats the shared fields in each type's declaration. You never write them out yourself.

FieldTypeWhat it does
ididentifier?Names the widget so an edge (a -> b) or another shape's anchor can reach it.
classlist<utf8>?Names class blocks. Their fill and stroke repaint this widget's box.
disabledbool?Draws the widget, and anything inside it, at 45% opacity.
x / yf64 (0.0)Top-left corner in the diagram. Ignored on a nested widget.
width / heightf64?Advisory. Honoured only by the three device frames.
anchor_left / anchor_right / anchor_top / anchor_bottomf64?Insets in pixels from the parent box's edges.
connect_pointslist<AnchorSide>?Which sides edges attach to: :north / :east / :south / :west. Default: all four.
theme / accent / modesymbol?UI-theme override. Read on the outermost widget only.

Anchor an edge, and declare a size with it

The anchors are pixel insets. They resolve from the declared width / height, not from the measured content. That is the one place the advisory size still matters. anchor_left = 20.0 puts the left edge 20px in, as expected. anchor_right = 20.0 computes parent − 20 − width, and width is 0 unless you declared it. The widget's left edge therefore lands 20px short of the right edge, and its body hangs off the canvas. Anchor from the left and the top, or declare an approximate width and height beside the right and bottom anchors.

Theming

A wireframe paints itself from the resolved UI theme: the site's ui_theme, ui_accent and ui_mode, which fall back to the document theme. The renderer bakes those colours into the SVG as literal hex rather than leaving them to CSS. That is why a wireframe looks the same in the book, in a PDF and in a slide deck. Themes covers the palettes.

Override the theme per mock-up with theme, accent or mode on the outermost widget. Every widget declares the three fields. The renderer reads only the one the diagram places, so a mode = :light on a nested button changes nothing.

For a single element, use a class instead. Declare it once at the top level of the document:

wcl
class wf_danger { fill = "#c0392b"  stroke = "#922b21" }

Then name it on the widget. A class's fill and stroke repaint that one box, wherever the widget sits:

Preview

Delete projectCancelDelete
Delete projectCancelDelete

Example

diagram {
  width = 280
  height = 110
  wf_window "Delete project" {
    wf_row {
      wf_button "Cancel"
      wf_button "Delete" {
        class = ["wf_danger"]
      }
    }
  }
}

The family at a glance

Eighteen blocks, one table. "Inline label" is what the value after the kind means."Children" says whether the widget may hold other widgets.

BlockGroupInline labelChildrenSized by
wf_windowFrameTitlebar captionYesContent, minimum 256 wide
wf_browserFrameAddress-bar URLYes640 × 440. Each axis pinnable, height grows to fit
wf_phoneFrameStatus caption (optional)Yes280 × 580. Each axis pinnable, height grows to fit
wf_tabletFrameStatus caption (optional)Yes480 × 640. Each axis pinnable, height grows to fit
wf_panelLayoutNone. title is a fieldYesContent
wf_rowLayoutNoneYesContent, laid out left to right
wf_columnLayoutNoneYesContent, laid out top to bottom
wf_gridLayoutNoneYesContent, flowed across columns columns
wf_labelControlThe textNoIts text
wf_buttonControlThe captionNoIts caption plus padding
wf_inputControlThe placeholderNoIts text, minimum 130 wide
wf_dropdownControlThe chosen optionNoIts text, minimum 130 wide
wf_checkboxControlThe captionNoBox plus caption
wf_radioControlThe captionNoDot plus caption
wf_toggleControlThe caption (optional)NoTrack plus caption
wf_node_graphGraphNonewf_node / wf_link onlyThe laid-out node bounding box
wf_nodeGraphThe titleNoTitle and port labels, minimum 92 wide
wf_linkGraphThe from endpointNoNot drawn as a box

Three rows repay a second look. wf_panel is the only widget whose caption is a field rather than a label. wf_node and wf_link are the only two blocks in the family that are not diagram shapes: their id names a link endpoint inside the graph, no edge in the diagram reaches them, and they have no life outside a wf_node_graph. And every "Yes" in the Children column means one thing: @children(Widget), any widget, any depth.

Writing your own widget

A widget is a diagram shape, so writing one is writing a shape. Declare a @block("…") type … extends Widget with a lower that returns list<Svg>, and place it in a diagram like any other. Writing your own blocks covers the lowering mechanism in full.

The family has no progress bar. Here is one, with a track, a fill in proportion to value, and a caption over both:

wcl
@block("wf_gauge")
type WfGauge extends Widget {
  @inline(0) label: utf8
  value = 0.5                    # 0.0 empty, 1.0 full

  # The shared Widget fields, repeated as every wf_* type repeats them.
  id: identifier?  class: list<utf8>?  disabled: bool?
  x = 0.0  y = 0.0  width = 160.0  height = 24.0
  anchor_left: f64?  anchor_right: f64?  anchor_top: f64?  anchor_bottom: f64?
  connect_points: list<AnchorSide>?
  theme: symbol?  accent: symbol?  mode: symbol?

  lower = fn(g: WfGauge) -> list<Svg> {
    [ Svg::Rect {
        x: g.x, y: g.y, width: g.width, height: g.height,
        rx: 4.0, fill: "#2f3542",
      },
      Svg::Rect {
        x: g.x, y: g.y, width: g.width * g.value, height: g.height,
        rx: 4.0, fill: "#4c8bf5",
      },
      Svg::Label {
        content: g.label,
        x: g.x + g.width / 2.0,
        y: g.y + g.height / 2.0 + 4.0,
        fit_width: g.width, fit_height: g.height,
        fill: "#ffffff",
      } ]
  }
}

diagram {
  width = 200  height = 60
  wf_gauge "Uploading" { x = 10.0  y = 10.0  value = 0.7 }
}

Two limits come with it. The first is sizing: the measuring pass belongs to the sixteen built-ins, so your widget sizes itself. Give the declaration a real width and height, and honour them in the lower.

The second is nesting. A container lays out the built-in kinds and drops the rest, so a custom widget is a top-level shape beside the frames. It is never a control inside one.

Where to go next