Skip to content

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

(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

(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 td

(td:)[body]

(td: value)

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 th

(th:)[body]

(th: value)

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:

  1. Per-table class: (table: "my-scores")[...] → selectors like .my-scores td { ... } via (css:) or styles/game.css.
  2. Inline style block: add (css: "...") immediately before the (table:).
  3. Game-wide: override .ana-table, .ana-table th, .ana-table td in styles/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:)["—"])
        ]
    ]
]

Tabs Macro

(tabs:) builds a tab strip with swappable content panes. It's a general-purpose layout, usable in any zone (modals, sidebars, main content), independent of (panel:). Switching tabs is a pure show/hide, so widgets inside a pane (sliders, panels, watches) keep their state when you switch away and back.

The macro family is handled directly by the executor (like (if:) and (each:)); no JS is required in the template.


Warning: (tab:) must only appear inside a (tabs:) block. Used outside, it silently no-ops.

tabs

(tabs:)[body]

Creates the tab strip and its content panes. The body contains one or more (tab:) sub-macros. You can declare them literally or generate them with (each:); a (tab:) created inside a loop captures the loop variable, so each pane renders with its own value.

The first tab is active on render; the rest are hidden until their button is clicked.

ana
(tabs:)[
    (tab: "Display")[ (button: $world.fontScale, "Large", 1.2) ]
    (tab: "Audio")[ (slider: $world.audioVolumeBgm, "Music", 0, 100, suffix, "%") ]
]

tab

(tab: "Label")[body]

Declares one tab. The argument is the tab-button label; the body is the pane content (any Ana macros, including a full (panel:)).

ana
// Dynamic tabs: one per distinct item type
(tabs:)[
    (each: _t in (query: $inv, select, "type", distinct))[
        (tab: (proper: _t))[
            (panel: $world.invSelectedItem)[
                (panel-item: _id, (filter: $inv.type is _t))[(get: $item, _id, "name")]
                (panel-detail: _id)[(if: _id)[(get: $item, _id, "name")](else:)[Pick one.]]
            ]
        ]
    ]
]

Default CSS

ClassElement
.ana-tabs-rootOuter container (strip + panes)
.ana-tabsThe tab-button row
.ana-tabIndividual tab button
.ana-tab.activeThe currently selected tab
.ana-tab-panelOne content pane per tab (only the active one is shown)

Override in styles/game.css or with (css:) before the tabs.


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

(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:).

The macro:

  • Builds the left-panel item list from the collection passed to (panel-item:)
  • 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 buttons

(panel:) is a pure master/detail widget with no tabs of its own. For a tabbed view (e.g. quests by status, items by type), wrap one (panel:) per tab inside (tabs:). The per-tab panels can all share one selection variable: a panel only renders a detail for a selection that is one of its own items, so switching to a tab whose list does not contain the current selection automatically shows that tab's placeholder instead of a stale detail from another tab. You do not need a separate selection variable per tab or a manual membership guard in the detail body.

ana
(panel: $world.selectedQuest)[
    (panel-item: _id, (quest-list: active))[(quest-title: _id)]
    (panel-detail: _id)[
        (if: _id)[
            (quest-title: _id)
            (quest-desc: _id)
        ](else:)[
            Select a quest to view details.
        ]
    ]
]

panel-item

(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. collection is any expression that returns an array of item IDs: typically a (quest-list:), (filter:), (ids:), (query:), or a $variable array.

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-list: active))[(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

(panel-detail: _id)[body]

Defines the right-panel detail template. _id is bound to $stateVar's current value when the detail renders, but only when that value is one of this panel's own items; otherwise _id is "". That means a panel sharing its selection variable with sibling panels (the per-tab pattern above) clears to its placeholder when the selection belongs to another panel. Always include an (if: _id)[...](else:)[...] to handle the empty-selection state, which doubles as the cross-tab clear.

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.

ClassElement
.ana-panelOuter grid container (list + detail)
.ana-panel-leftLeft column: scrollable item list
.ana-panel-listItem list container
.ana-panel-itemIndividual item button
.ana-panel-item--selectedThe currently selected item
.ana-panel-rightRight column: detail area
.ana-panel-detail-innerWrapper inside the right column

Override in styles/game.css or with (css:) before the panel.


Full example: quest log (tabs + panel)

ana
(tabs:)[
    (tab: "Active")[
        (panel: $world.selectedQuest)[
            (panel-item: _id, (quest-list: active))[(quest-title: _id)]
            (panel-detail: _id)[
                (if: _id)[
                    (html: "<div class='ql-title'>" + (quest-title: _id) + "</div>")
                    (html: "<div class='ql-desc'>"  + (quest-desc:  _id) + "</div>")
                ](else:)[Select a quest.]
            ]
        ]
    ]
    (tab: "Completed")[
        (panel: $world.selectedQuest)[
            (panel-item: _id, (quest-list: complete))[(quest-title: _id)]
            (panel-detail: _id)[
                (if: _id)[(quest-title: _id)](else:)[Select a quest.]
            ]
        ]
    ]
]

Full example: inventory categories from item types

Build one tab per distinct item type with (query:)'s select/distinct, plus an "All" tab:

ana
(set: _cats to (join: (arr: "All"), (query: $inv, select, "type", distinct)))
(tabs:)[
    (each: _cat in _cats)[
        (set: _items to (ids: $inv))
        (if: _cat is not "All")[(set: _items to (filter: $inv.type is _cat))]
        (tab: (proper: _cat))[
            (panel: $world.invSelectedItem)[
                (panel-item: _id, _items)[
                    (get: $item, _id, "name")
                    (if: (count: $inv, _id) > 1)[ ×(count: $inv, _id)]
                ]
                (panel-detail: _id)[
                    (if: _id)[
                        (html: "<strong>" + (get: $item, _id, "name") + "</strong>")
                    ](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).

SymbolWidth
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;
}

Class argument

The inline form macros (input, input-box, checkbox, dropdown, slider, button) accept an optional class, "name" pair to add your own class to the widget's wrapper element, on top of its built-in classes. Use it to style one widget differently from the rest. It may sit in any position after the required arguments, and works alongside the size symbol.

ana
(dropdown: $world.theme, "Theme", "Dark", "Light", large, class, "settings-dd")
css
.settings-dd select { border-color: var(--ana-accent); }

(link:) / (link-if:) / (link-once:) take the same class, "name" pair (the class goes on the link's <button>), so you can style specific buttons without touching every link.


input and

(input: $var, "Initial Value") · (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 and

(input-box: $var, rows, "Initial Value") · (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 and

(input-forced: "ForcedText")[body] · (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 and

(input-box-forced: rows, "ForcedText")[body] · (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

(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)

slider

(slider: $var, "Label", min, max) · (slider: $var, "Label", min, max, step, N, suffix, "%")

Renders a labelled range slider (<input type="range">) bound to a numeric $var, with a live value readout on the right. Sets $var on every drag. min and max are required; the optional step, N pair sets the granularity (default 1), and the optional suffix, "x" pair is appended to the readout (e.g. "%"). Like the other form widgets it reads $var at render and writes back on input; it is not reactive to changes made elsewhere.

ana
(slider: $world.audioVolumeBgm, "Music", 0, 100, suffix, "%")
(slider: $player.brightness, "Brightness", 0, 10, step, 0.5)

The three-column layout (.ana-slider-row → label, track, value) and thumb colour are styled by styles/engine.css; override .ana-slider, .ana-slider-label, and .ana-slider-value (or pass a class, "name" pair) to restyle. See Class argument.


button

(button: $var, "Label", "Value") · (button: $var, "Label", "Value", class, "name")

A stateful toggle button. Clicking sets $var to "Value". Unlike (link:), it is reactive: while $var equals "Value" the button carries the .on class and aria-pressed="true", updating live whenever $var changes (by this button, a sibling, or anything else). Put several side by side with the same $var and different values to get a segmented control / radio group.

ana
(button: $world.difficulty, "Easy", "easy")
(button: $world.difficulty, "Normal", "normal")
(button: $world.difficulty, "Hard", "hard")

Value keeps its type, so (button: $player.lives, "Three", 3) stores the number 3. The button only sets the variable; for side effects beyond that (apply a theme, write localStorage), pair it with a (watch: $var)[…] that reacts to the change, or use a (link:)[block] instead. Styled by .ana-button / .ana-button.on in styles/engine.css; restyle those or pass a class, "name" pair.


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.

Dialogs render as overlays and never touch the triggering passage's zones, so you can open one from an (action:) (or (update:)) without clearing the page behind it. To show a message in response, route it through an (action:) in the body rather than calling (notify:) directly (see below).

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. Plain output macros such as (notify:) placed directly in the body are not rendered; branch to an (action:) passage for those.


confirm

(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

(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

(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

(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

(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

(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

(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

(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 default

Typical use: a settings screen slider that calls (font-scale: _scale).

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.