The canonical reference for Wyreframe ASCII wireframe syntax, version 2.3.0. This document covers the wireframe syntax only; the Interaction DSL is parsed separately and is not part of v2.3.
Parse this syntax with the V2 parser:
import { parse } from 'wyreframe/parser/v2';
const result = parse(source);- Block Types
- Core Elements
- ID System
- String Literals
- Emoji Shortcodes
- Prop Placeholders
- Implicit Layout
- Parsing Priority
- Unicode and Whitespace
- Errors and Warnings
Every wireframe file consists of one or more top-level blocks. A block starts with a @scene: or @component: declaration. Blocks cannot be nested — a nested declaration is an error (NestedBlockDeclaration), and a file with no declaration at all is an error (MissingBlockDeclaration).
@scene: login # required — unique slug
@title: Login Page # optional — page title
@device: mobile # optional — mobile | tablet | desktop
@transition: fade # optional — default transition effect@component: user-card # required — unique slug
@props: name, avatar? # optional — comma-separated; `?` suffix = optional propDuplicate prop names emit DuplicatePropName (last declaration wins).
A box drawn with + (corners), - (horizontal borders), and | (walls):
+--Header-----------+ <- top border, optional name
| "Welcome" | <- body rows: children of the container
| [ Start ] |
+-------------------+ <- bottom border closes the container
- The text embedded in the top border (
+--name--+) becomes the container'sname. A plain border (+-----+) yields a name-less container. - Containers nest recursively; a nested container must fit completely within its parent's walls.
- A missing bottom border raises
UnclosedContainer(with recovery). - Wall/corner alignment is tolerant: drift within
containerColumnTolerance(default ±1 col) parses with a warning (MisalignedContainerWall,MisalignedContainerCorner); border width mismatch withincontainerWidthTolerance(default ±2) warns withInconsistentContainerWidth. - Nesting deeper than
maxDepth(default 10) raisesMaxDepthExceeded; the offending container parses as empty and is markedcontainsErrorRecovery: true.
Any content that matches no other pattern is plain text (the lowest-priority fallback):
| Welcome back! |
Alignment (Left / Center / Right) is inferred from the text's position within its container (see centerSymmetryThreshold, rightAlignThreshold heuristics).
[ text ] — square brackets with spaces around the label:
[ Login ]
[ Sign Up ]
[ ] <- empty button (2+ spaces)
- The label is stored as
text; anidis auto-generated by slugifying the text ([ Sign Up ]→sign-up). - Patterns that also match Select/Input/Checkbox are not buttons (see Parsing Priority).
< text > — angle brackets:
< Forgot password >
< Back to home >
The label is stored as text with an auto-slugged id.
[__placeholder__] — double underscores inside brackets:
[__email__]
[__password______________]
[____________] <- empty placeholder
[__my__var__] <- placeholder: "my__var" (first `__` to last `__`)
A pattern that opens with [__ but never closes with __] raises UnclosedInput (recovery is row-bounded — it never crosses lines).
[v: placeholder] — dropdown:
[v: Choose a country]
The text after v: becomes placeholder; the id is auto-slugged from it.
A 3-character bracket marker followed by a label:
[x] Remember me <- checked ([x], [X], [v], [V])
[ ] Subscribe <- unchecked (exactly one space)
A checkbox without a label emits MissingCheckboxLabel.
(*) / ( ) followed by a label:
(*) Credit card
( ) Bank transfer
( ) Crypto
Radios are auto-grouped when they are:
- vertically consecutive (within
radioMaxBlankRows, default 0 blank rows, andradioVerticalColumnTolerance, default ±1 col), or - side by side on the same row (within
radioHorizontalGap, default 6 cols), or - inside the same container.
A radio without a label emits MissingRadioLabel. Multiple selected radios in one group emit MultipleRadiosSelected.
Horizontal rule made of dashes or equals signs (3+ recommended, dividerMinRun default 3):
--- <- normal divider
=== <- bold divider
--- Section --- <- labeled divider
=== Payment === <- labeled bold divider
---#section-1--- <- divider with ID
-#top- <- short form with ID
A divider mixing a label and an ID (--- text #id ---) emits MixedDividerLabelId and is treated as text.
Containers can be given an explicit ID for later reference:
Format 1 — ID in the top border:
+--#login-form--+
| [ Submit ] |
+---------------+
Short forms +#id+ and +-#id-+ are also accepted. Hyphenated IDs (#login-form) are supported; a trailing border dash is never consumed into the ID.
Format 2 — standalone ID line:
+---------------+
| #login-form |
| [ Submit ] |
+---------------+
Rules:
- A Format 2 line must contain only the ID —
| #id text |raisesInvalidIdFormat. - If both formats appear, Format 1 wins and the Format 2 line is treated as plain text.
- Two or more
#idpatterns in one container raiseMultipleIdDeclarations. - Duplicate container IDs across the block emit
DuplicateContainerId.
"text" — double-quoted strings disable inner syntax parsing:
"Click [ here ] for details" <- [ here ] is plain text, not a button
"He said \"hello\"" <- escaped quote
"C:\\path\\to\\file" <- escaped backslash
"Costs \$5" <- escaped dollar (prevents prop interpolation)
"Hello ${name}" <- prop interpolation (inside @component)
- Strings may span multiple lines; the line break is preserved (
multiline: true). - A
"without a closing quote raisesUnclosedString.
:name: converts to a Unicode emoji. 14 built-in shortcodes:
| Shortcode | Emoji | Shortcode | Emoji |
|---|---|---|---|
:check: |
✔ | :home: |
🏠 |
:cross: |
✘ | :mail: |
✉ |
:warning: |
⚠ | :bell: |
🔔 |
:info: |
ℹ | :lock: |
🔒 |
:heart: |
❤ | :bow: |
🙇 |
:star: |
⭐ | :search: |
🔍 |
:user: |
👤 | :settings: |
⚙ |
- Unknown shortcodes emit
UnknownEmojiand render as plain text. - Custom shortcodes can be supplied per parse via
parseOptions.emojiRegistry.
${prop} inserts a component prop value. Valid only inside @component blocks:
@component: greeting
@props: name, title?
+---------------------------+
| "Hello, ${name}!" |
| ${title?} | <- optional prop
| ${role:Member} | <- default value "Member"
+---------------------------+
| Form | Meaning |
|---|---|
${prop} |
Required prop |
${prop?} |
Optional prop (required: false) |
${prop:default} |
Optional with default value (colons allowed in the default) |
${prop}inside a@sceneblock emitsPropOutsideComponentand is preserved as literal text.- A placeholder referencing a name not in
@propsemitsUnknownPropReference.
No layout keywords exist — arrangement is inferred from position:
- Elements whose first non-whitespace token starts on the same visual row form a Row.
- Elements on different rows stack as a Column.
- A multiline container's start row is its top-border (
+--) row. - Spacing between elements never affects direction — it only informs the optional
distributionfield.
+--------------------------------+
| [ Cancel ] [ Confirm ] | <- Row
| |
| "Terms apply" | <- Column item below the row
+--------------------------------+
Layout output (layoutInfo) addresses children by index range into parent.children — child nodes are never duplicated.
At each source position, element parsers are probed in descending priority; the first match wins:
| Priority | Element | Pattern |
|---|---|---|
| 115 | String | "..." (incl. multiline) |
| 110 | Container ID | +--#id--+, | #id | |
| 105 | PropPlaceholder | ${...} |
| 100 | Emoji | :name: |
| 95 | Select | [v: ...] |
| 90 | Input | [__...__] |
| 85 | Radio | (*), ( ) |
| 80 | Checkbox | [x], [ ] (3 chars) |
| 70 | Button | [ ... ] |
| 60 | Link | < ... > |
| 50 | Divider labeled bold | === text === |
| 48 | Divider labeled | --- text --- |
| 45 | Divider ID | -#id- |
| 40 | Divider | ---, === |
| 10 | Container | +--+ |
| 1 | Text | fallback |
The bracket family ([ ]) disambiguates as Select > Input > Checkbox > Button. Input that is one token away from a known pattern triggers a near-miss warning (LooksLikeButton, LooksLikeInput, LooksLikeCheckbox, LooksLikeRadio).
- Input is UTF-8; LF, CRLF, and standalone CR line endings are supported.
- Columns are visual columns: East Asian Wide characters and emoji grapheme clusters count as 2, combining marks as 0. ZWJ sequences and surrogate pairs are never split.
- Tabs expand per
parseOptions.tabSize(default 4). - Bidi/RTL text is out of scope for v2.3 (treated as LTR for column counting).
- All positions are 0-based
row/col/offset(converted to 1-based only for human display).
| Code | Message |
|---|---|
InvalidIdFormat |
Invalid ID format - ID line must contain only #id |
MultipleIdDeclarations |
Multiple ID declarations in container |
UnclosedInput |
Unclosed Input boundary - missing '__]' |
UnclosedString |
Unclosed string literal - missing '"' |
UnclosedContainer |
Unclosed container - missing bottom border |
MissingBlockDeclaration |
Missing block declaration - add @scene: or @component: |
NestedBlockDeclaration |
@scene/@component cannot be nested inside another block |
MaxDepthExceeded |
Container nesting exceeded maxDepth |
Every error carries code, message, a location (start/end positions), and a recoverable flag. In non-strict mode the parser recovers and keeps parsing; strict: true promotes all errors to fatal and halts at the first one.
Spec-mandated:
| Code | Trigger |
|---|---|
PropOutsideComponent |
${prop} in a @scene block |
UnknownEmoji(name) |
Unregistered :shortcode: |
MixedDividerLabelId |
--- text #id --- |
MissingCheckboxLabel |
[x] with no label |
MissingRadioLabel |
(*) with no label |
Heuristic-driven (each carries a ruleId for traceability):
| Code | ruleId |
|---|---|
MisalignedContainerCorner |
container.cornerAlignment |
MisalignedContainerWall |
container.wallAlignment |
InconsistentContainerWidth |
container.widthConsistency |
RadioGroupAmbiguous |
radioGrouping.container |
LooksLikeButton / LooksLikeInput / LooksLikeCheckbox / LooksLikeRadio |
nearMissPatterns |
Cross-cutting (Validator):
| Code | Trigger |
|---|---|
DuplicatePropName(name) |
Same prop declared twice (last wins) |
DuplicateContainerId(id) |
Same container id used twice |
UnknownPropReference(name) |
${name} not in @props |
MultipleRadiosSelected(group) |
More than one (*) in a group |
All tolerances are overridable via parseOptions.heuristics:
| Heuristic | Default | Purpose |
|---|---|---|
containerColumnTolerance |
1 | ± cols for wall/corner alignment |
containerWidthTolerance |
2 | ± cols for top/bottom border width match |
radioHorizontalGap |
6 | Max col gap for same-row radio grouping |
radioVerticalColumnTolerance |
1 | ± cols for vertical radio grouping |
radioMaxBlankRows |
0 | Blank rows allowed inside a vertical radio group |
centerSymmetryThreshold |
0.15 | Symmetry ratio for center-align detection |
rightAlignThreshold |
0.10 | Right-margin ratio for right-align detection |
dividerMinRun |
3 | Minimum dash/equals run for a divider |
nearMissTokenDistance |
1 | Token edit distance for near-miss warnings |
| Concept | V1 (legacy) | v2.3 |
|---|---|---|
| Input field | #id |
[__placeholder__] |
| Link | "text" |
< text > |
| String literal | — | "text" |
| Emphasis/title | 'text' |
— (use Text/String) |
| Select | — | [v: placeholder] |
| Radio | — | (*) / ( ) |
| Component + props | — | @component: / @props: / ${prop} |
| Emoji shortcodes | — | :name: |
| Container ID | — | +--#id--+ / | #id | |
V1 syntax is still consumed by the V1 parser and renderer (wyreframe main export). See api.md.
Spec version: 2.3.0
Parser: wyreframe/parser/v2 (implementation: "rescript-v2")