Appearance
Tables, Panels, Forms & Accessibility
Structured display and input widgets: data tables, master/detail panels, form controls, and accessibility helpers.
Table Macros
(table:), (tr:), (td:), and (th:) produce themed HTML tables without hand-coded markup. They are handled directly by the executor alongside (if:) and (each:), so blocks work as expected and any Ana macro can appear inside a cell.
The capture-queue mechanism: each block-form macro executes its body into a temporary render queue, collects all output as an HTML string, wraps it in the appropriate tag, and pushes a single rawhtml entry to the parent queue. This means nesting is recursive and correct.
Macros in cells: Macros inside table cells are fully supported: (if:), (each:), (html:), expression macros, reactive (watch-var:), and so on all work inside (td:)[...] or (th:)[...] blocks. Navigation macros ((goto:), (update:), (action:)) do not make sense inside a cell directly; use (link:) for clickable cell content.
(table:)[body]
(table: "extra-class")[body]
Opens a <table class="ana-table">. The optional positional argument appends an additional CSS class (ana-table extra-class). Body should contain (tr:) rows.
ana
(table:)[
(tr:)[(th: "Name")(th: "Score")]
(each: _entry in $leaderboard)[
(tr:)[(td: _entry.name)(td: _entry.score)]
]
]With a custom class for per-table styling:
ana
(css: ".scores td:last-child { text-align: right; }")
(table: "scores")[
(tr:)[(th: "Player")(th: "Rank")(th: "Points")]
(each: _id in $players)[
(tr:)[(td: _name)(td: _rank)(td: _points)]
]
](tr:)[body]
(tr: "class")[body]
A table row <tr>. The optional positional argument sets a CSS class on the row, useful for conditional row styling:
ana
(set: _cls to "")
(if: (achieve-unlocked: _id))[(set: _cls to "ach-won")]
(tr: _cls)[
(td: _icon)
(td: (achieve-name: _id))
](td: value)
(td:)[body]
A table data cell <td>. Two forms:
- Simple:
(td: expression)evaluates the expression and uses the result as cell text. - Block:
(td:)[body]executes the block; any output (prose,(html:), conditionals, etc.) becomes the cell content.
ana
// Simple — expression result as text
(td: (achieve-name: _id))
(td: $player.gold)
// Block — rich content inside a cell
(td:)[(if: done)[★](else:)[○]]
(td:)[
(html: "<strong>" + _name + "</strong>")
(html: "<br><small>" + _desc + "</small>")
](th: value)
(th:)[body]
A table header cell <th>. Identical forms to (td:) but renders as <th> (bold, uppercase, muted; see CSS below).
ana
(tr:)[(th: "")(th: "Achievement")(th: "Progress")]Default CSS
The .ana-table class is defined in styles/engine.css and uses --ana-* custom properties, so it automatically fits any game theme.
css
/* All properties use CSS custom properties — override in styles/game.css */
.ana-table { width: 100%; border-collapse: collapse; font-size: .88em; }
.ana-table th { font-size: .72em; text-transform: uppercase; opacity: .42; ... }
.ana-table td { padding: .48rem .75rem; border-bottom: 1px solid var(--ana-border-subtle); }
.ana-table tbody tr:hover td { background: var(--ana-link-bg); }Override options:
- Per-table class:
(table: "my-scores")[...]→ selectors like.my-scores td { ... }via(css:)orstyles/game.css. - Inline style block: add
(css: "...")immediately before the(table:). - Game-wide: override
.ana-table,.ana-table th,.ana-table tdinstyles/game.css.
Complete example: achievement table
ana
(css: "
.ach-table .ach-icon { text-align: center; }
.ach-table tr.won td { border-left: 2px solid rgba(200,170,60,.4); }
")
(table: "ach-table")[
(tr:)[(th: "")(th: "Achievement")(th: "Progress")]
(each: _id in (achieve-list: all))[
(set: _done to (achieve-unlocked: _id))
(set: _icon to (if: _done)["★"](else:)["○"])
(set: _cls to (if: _done)["won"](else:)[""])
(tr: _cls)[
(td:)[(html: "<span class='ach-icon'>" + _icon + "</span>")]
(td:)[
(html: "<strong>" + (achieve-name: _id) + "</strong>")
(html: "<br><small>" + (achieve-desc: _id) + "</small>")
]
(td: (if: _done)["Complete"](else:)["—"])
]
]
]Panel Macro
(panel:) builds a two-panel master/detail layout: a scrollable list on the left, a lazy-rendered detail view on the right. Clicking an item in the list immediately renders its detail without reloading the modal.
The macro family is handled directly by the executor (like (if:) and (each:)), so event listeners are wired directly to the panel buttons at render time and no JS in the template is required.
Warning:
(panel-item:)and(panel-detail:)must only appear inside a(panel:)block. Using them outside of(panel:)[...]has no effect; the macros silently no-op.
(panel: $stateVar)[body]
Creates the two-panel $container. $stateVar is the global variable that tracks which item is currently selected (e.g., $world.selectedQuest). Body must contain (panel-item:) and (panel-detail:), and optionally one or more (panel-filter:) entries.
The macro:
- Builds the left-panel item list (with filter tabs if provided)
- Creates an empty right-panel container
- Renders the detail template into the right panel immediately on mount (showing the current selection or the else branch)
- Wires click listeners on all item and tab buttons
ana
(panel: $world.selectedQuest)[
(panel-filter: "Active", (quest-list: active))
(panel-filter: "Available", (quest-list: available))
(panel-filter: "Completed", (quest-list: complete))
(panel-item: _id)[(quest-title: _id)]
(panel-detail: _id)[
(if: _id)[
(quest-title: _id)
(quest-desc: _id)
](else:)[
Select a quest to view details.
]
]
](panel-filter: "Label", collection)
Adds a filter tab to the left panel. "Label" is the tab button text. collection is any expression that returns an array of item IDs: typically a (quest-list:), (filter:), (ids:), or a $variable array.
Multiple (panel-filter:) calls define multiple tabs. Items are only shown in the tab whose filter returned them. The tab that contains the currently selected item is automatically made active on open.
Omit (panel-filter:) entirely for a flat list (no tabs); use (panel-item: _id, collection) instead.
ana
(panel-filter: "Active", (quest-list: active))
(panel-filter: "Weapons", (filter: $inv.type is "weapon"))
(panel-filter: "Items", (filter: $inv.type is not "weapon"))(panel-item: _id)[body]
(panel-item: _id, collection)[body]
Defines the item template for the left-panel list. _id (or any temp variable name) is bound to each item ID when rendering the button content.
- With filters:
(panel-item: _id)lets each filter provide its own items. The same template renders for all tabs. - No filters:
(panel-item: _id, collection)takes the flat item collection as its second argument.
The body can use any Ana macro. Expression macros used as statements (like (quest-title: _id)) output their value as text. (html:) is available for richer formatting.
ana
// Simple text label
(panel-item: _id)[(quest-title: _id)]
// With item count
(panel-item: _id, (ids: $inv))[
(get: $item, _id, "name")
(if: (count: $inv, _id) > 1)[ ×(count: $inv, _id)]
](panel-detail: _id)[body]
Defines the right-panel detail template. _id is bound to $stateVar's current value when the detail renders. Always include an (if: _id)[...](else:)[...] to handle the empty-selection state.
The body renders lazily: only the currently selected item's detail is in the DOM at any time. The body re-executes whenever the selection changes.
ana
(panel-detail: _id)[
(if: _id)[
(html: "<strong>" + (quest-title: _id) + "</strong>")
(quest-desc: _id)
(if: (quest-stage-desc: _id))[
(quest-stage-desc: _id)
]
](else:)[
(html: "<span class='panel-placeholder'>Select an item.</span>")
]
]Default CSS
The panel uses --ana-* custom properties throughout, so it fits any game theme automatically.
| Class | Element |
|---|---|
.ana-panel | Outer grid container (tabs top, list+detail bottom) |
.ana-panel-tabs | Full-width tab row (grid row 1, spans both columns) |
.ana-panel-tab | Individual tab button |
.ana-panel-tab.active | The currently selected tab |
.ana-panel-left | Left column: scrollable item list (grid row 2, col 1) |
.ana-panel-list | Item list container per tab |
.ana-panel-item | Individual item button |
.ana-panel-item--selected | The currently selected item |
.ana-panel-right | Right column: detail area (grid row 2, col 2) |
.ana-panel-detail-inner | Wrapper inside the right column |
Override in styles/game.css or with (css:) before the panel.
Full example: quest log
ana
(panel: $world.selectedQuest)[
(panel-filter: "Active", (quest-list: active))
(panel-filter: "Available", (quest-list: available))
(panel-filter: "Completed", (quest-list: complete))
(panel-item: _id)[(quest-title: _id)]
(panel-detail: _id)[
(if: _id)[
(set: _stat to (quest-status: _id))
(html: "<div class='ql-title'>" + (quest-title: _id) + "</div>")
(html: "<div class='ql-desc'>" + (quest-desc: _id) + "</div>")
(if: (quest-stage-desc: _id))[
(html: "<div class='ql-stage'>" + (quest-stage-desc: _id) + "</div>")
]
](else:)[
Select a quest.
]
]
]Full example: inventory with categories
ana
(panel: $world.invSelectedItem)[
(panel-filter: "Consumables", (filter: $inv.type is "consumable"))
(panel-filter: "Weapons", (filter: $inv.type is "weapon"))
(panel-filter: "Other", (filter: $inv.type is not "consumable"))
(panel-item: _id)[
(get: $item, _id, "name")
(if: (count: $inv, _id) > 1)[ ×(count: $inv, _id)]
]
(panel-detail: _id)[
(if: _id)[
(html: "<strong>" + (get: $item, _id, "name") + "</strong>")
(if: (get: $item, _id, "type") is "consumable")[
(link: "Use")[(set: $world.invSelectedItem to _id)(action: _Inv_UseItem)]
]
](else:)[
Select an item to view details.
]
]
]Form Inputs
Inline form elements and dialog popups. Bind to $var and respond to player input.
Size argument
All inline form macros (input, input-box, checkbox, dropdown) accept an optional bare-symbol size as the final argument. Write it without quotes; the parser distinguishes bare large (a symbol) from "large" (a string option value).
| Symbol | Width |
|---|---|
small | --ana-input-width-small (default 140 px) |
medium | --ana-input-width-medium (default 260 px), default if omitted |
large | --ana-input-width-large (default 480 px) |
Override widths in styles/game.css:
css
:root {
--ana-input-width-small: 120px;
--ana-input-width-medium: 300px;
--ana-input-width-large: 600px;
}(input: $var, "Initial Value") and (input: $var, "Initial Value", size)
Renders a single-line text field. Pre-populated with "Initial Value". Updates $var on every keystroke.
ana
(input: $player.name, "Hero")
(input: $player.name, "Hero", large)(input-box: $var, rows, "Initial Value") and (input-box: $var, rows, "Initial Value", size)
Same as (input:) but renders a multi-line textarea. The second argument is the number of visible rows.
ana
(input-box: $player.bio, 4, "Write your backstory...")
(input-box: $player.bio, 6, "Write your backstory...", large)(input-forced: "ForcedText")[body] and (input-forced: "ForcedText", size)[body]
A text field where the character is not in control of what they type. Each keypress the player makes substitutes the corresponding character from "ForcedText", letter for letter, position by position. The player's actual input is ignored; the forced text is revealed instead.
When the last character of "ForcedText" has been "typed", the field is disabled and the block body executes (if provided).
ana
Are you going to be a good girl?
(input-forced: "Yes, Daddy!", medium)[(update: Brainwashed_Response)]Use cases: brainwashing or puppeting effects, forced confessions, any scene where the character's autonomy over their own words is stripped away.
(input-box-forced: rows, "ForcedText")[body] and (input-box-forced: rows, "ForcedText", size)[body]
Same as (input-forced:) but renders as a multi-line textarea.
ana
(input-box-forced: 3, "I will obey. I will not resist. I belong to you.", large)[
(action: Oath_Complete)
](checkbox: $var, "Label")
Renders a labelled checkbox. Initializes checked state from $var (boolean). Updates $var to true/false on every click.
ana
(checkbox: $settings.autosave, "Enable autosave")
(checkbox: $player.consented, "I agree to the terms")(dropdown: $var, "Label", "opt1", "opt2", ..., size?)
Renders a labelled <select> dropdown. If $var already matches one of the option strings, that option is pre-selected. Updates $var to the selected string on change.
ana
(dropdown: $player.hair, "Hair color", "Black", "Brown", "Red", "Blonde", "White")
(dropdown: $player.class, "Class", "Rogue", "Mage", "Warrior", large)Dialog Popups
Dialog macros open a floating popup over the current passage. They do not take a size argument. All three support an optional block body that executes when the popup closes, after $var has been set, allowing authors to branch on the response inline.
The block body supports: (if:), (set:), (add:), (sub:), (goto:), (update:), (action:). When a navigation macro is present in the body, the first one found fires after the popup closes.
(confirm: $var, "Question", "Yes", "No")[body]
A two-button confirmation popup. The third argument is the confirm button label, the fourth is the decline label (any text). Sets $var to true if confirmed, false if declined.
ana
(confirm: $accepted, "Delete this save file?", "Delete", "Keep it")[
(if: $accepted is true)[(action: DeleteSave)]
]
(confirm: $sure, "Are you absolutely certain?", "Hell yes", "Nevermind")(dialog: $var, "Question", "ans1", "ans2", ...)[body]
A multi-button dialog. Renders one button per answer string. Sets $var to the string of the chosen answer.
ana
(dialog: $response, "How do you answer?", "Charm him", "Be direct", "Say nothing")[
(if: $response is "Charm him")[(goto: Bar_CharmPath)]
(elseif: $response is "Be direct")[(goto: Bar_DirectPath)]
(else:)[(goto: Bar_SilentPath)]
](prompt: $var, "Question", "Initial text", "Submit label")[body]
A text-input popup. Shows a question, a pre-filled text field, and a submit button. Sets $var to whatever the player typed. The player can also submit by pressing Enter.
ana
(prompt: $player.name, "What is your name?", "Hero", "Confirm")[
(goto: CharCreation_Next)
](prompt-forced: $var, "Question", "ForcedText", "Submit label")[body]
Same structure as (prompt:) but the text field uses the forced-input mechanic: each keypress substitutes the next character from "ForcedText". The submit button activates only after all forced characters have been typed. Sets $var to the forced text.
ana
(prompt-forced: $oath, "Repeat after me:", "I am yours.", "Confirm")[
(goto: Oath_Accepted)
]Zone Mutation
Normally an (action:) (or @zone() write) replaces the content of any zone it writes to. The zone-mutation macros instead add to (or overwrite) a named zone's live DOM without rebuilding it, which is what you want for incremental reveals, a running log, or a growing list. They are most useful inside action passages and link bodies.
All three take the zone name as a string and a block body. Content added this way is transient: it is wiped on the next (goto:) (full teardown) or any (update:)/(action:) that legitimately rebuilds that zone, which is the correct lifecycle for transient UI.
(append: "zone")[body]
Adds the body to the end of the zone's existing content.
ana
:: Combat_Hit [action]
(append: "log")[You strike for 6 damage.](prepend: "zone")[body]
Adds the body to the start of the zone's existing content (newest-first logs).
ana
(prepend: "log")[--- turn $world.turn ---](replace: "zone")[body]
Clears the zone (running its binding cleanups) and writes the body: an explicit, targeted overwrite of a zone other than the one your passage naturally flows into.
ana
(replace: "status")[HP: $player.hp / $player.maxHp]Accessibility
(font-scale: N)
Sets the --ana-font-scale CSS custom property and persists the value to localStorage. The scale multiplies the base --ana-font-size. Restored automatically on page load.
ana
(font-scale: 1.2) // 20% larger text
(font-scale: 1.0) // reset to defaultTypical use: a settings screen slider that calls (font-scale: _scale).
Aria labels on links
All (link:) variants accept aria, "label" as a positional pair to set an accessible label:
ana
(link: "→", aria, "Go to the market")[(goto: Market)]
(link-if: $item.equipped, "⚔", aria, "Unequip sword")[(action: Unequip_Sword)]
(link-once: "★", aria, "Examine the bulletin board")[(update: Bulletin_Board)]Screen-reader roles
Navigation buttons (goto, update) render with role="link". Action buttons (action) use the default role="button". No author action required.