Tuimark for frontend devs
A map from the web to Tuimark for people who know HTML, CSS, and React or Vue - flex layout, stylesheets, components, form controls, focus, data binding, and events - and what Tuimark deliberately does not have.
If you build interfaces for the browser, most of Tuimark will be familiar, and a few things will not. You describe a screen in markup, style it with a stylesheet, bind it to data, and name the events. What changes is where the logic lives: a Tuimark view is data in, named actions out, and the program that hosts it (Go, or TypeScript on Bun) owns everything that is not drawing.
The map
| On the web | In Tuimark | Read |
|---|---|---|
display: flex, flex-direction, gap, flex-grow | <col> and <row>, gap, justify, align, and fr units (width: 1fr) | Layout |
| CSS grid | layout: grid with grid-columns and grid-min-width | Layout |
| A stylesheet, classes, ids, specificity, custom properties | TCSS: .class, #id, :focus, the same cascade by specificity, and $token colors | TCSS |
| Media queries | @media (max-cols: 80) | Layout |
| A component, with props | A file you <include>; a <template> you stamp with <use template="…" with="path as alias"/>, such as the kit's (k-confirm, k-field, …) once you include kit/kit.tui | The .tui language, Kit |
<input>, checkbox, <select>, radio buttons, <button> | <input>, <checkbox>, <select>, a <list> with single="true", <button> | Widgets |
overflow: auto, a scrolling <div> | <scroll>, or overflow: scroll | Layout |
| A modal dialog | <modal>, which traps focus while it is open | Widgets |
:focus, tabindex, the tab order | :focus, focusable="true", tab and shift+tab in document order | Keymap, actions and focus |
| Keyboard shortcuts from a library | <keymap> with <bind keys="…" action="…"/> | Keymap, actions and focus |
{{ value }}, v-model, value={state} | {path} in text, and bind="path" on widgets that hold state | Bindings and data |
v-for, items.map(…) | each="items as item" with a key | Bindings and data |
v-if, {cond && …} | if="path" and if="!path" | Bindings and data |
onClick, onChange, onSubmit | on:click, on:change, on:submit: each names an action that the host handles | Keymap, actions and focus |
| A browser test (Playwright) | tuimark play, golden dumps, and Go tests | Testing |
One screen, both halves
The view names what can happen. It does not say what happens.
<col gap="1" pad="1">
<input id="query" bind="query" placeholder="search tickets"/>
<checkbox id="archived" bind="show_archived" label="Show archived"/>
<list id="tickets" each="tickets as t" key="t.id" bind="selected" on:select="open">
<item><text>{t.id} {t.title}</text></item>
</list>
<button id="new" label="New ticket" on:click="new_ticket"/>
</col>query, show_archived, and selected are paths in a JSON store. Typing in the input writes query; toggling the checkbox writes show_archived; the list's cursor writes selected. The program that hosts the view reacts to the actions, here with the TypeScript client:
ui.on("open", async (ev) => {
await ui.set("detail", await api.ticket(ev.keys.t)); // ev.keys.t is the row's key
});
ui.on("new_ticket", async () => {
await ui.set("tickets", await api.createTicket());
});There is no useState: the store is the state, widgets keep their own (a cursor, the text being typed) by id, and the host changes the rest with set. See the TypeScript client or the Go API.
What is not there
These are choices, and they will not change:
- No expressions in markup. There is no
a + b, nocount > 0, no ternary, no filter, no format function, and no array index. A{ x }that holds more than a path isV003. When the view needs a derived value (a count, a label, the filtered rows), the host computes it and puts it in the store. A Go host can keep one withDerive, so the markup still reads a plain path. - A closed vocabulary. Tags, attributes, CSS properties, and units are a fixed catalog. There are no custom elements and no
<script>. An unknown tag isV001, an unknown attributeV002, an unknown property or unitV003. You reuse markup with<include>and<template>, not by defining new tags. - The host owns the logic. An
on:*attribute is a name, and nothing in the document says what it does. A name with no handler does nothing, so a view can be written and tested before its host exists. - No
:hover, and no pixels. A terminal is a grid of cells, and the keyboard comes first. Sizes are cells, percentages, andfrshares; the mouse is off until you ask for it with<tui mouse="…">.
Habits that carry over, and one that does not
- Layout is flex, and
frworks likeflex-grow. In the current language a nestedcolorrowis as big as its content until you give itwidth: 1fr(orheight), like a flex child with noflex-grow; see the current language's defaults. - Forms are controls with a
bind, and a control that is not a text field has its own keys:spacetoggles a checkbox,enteropens a select. Focus is always one node, and the look of the focused control comes from:focus. - Check your work the way you would in a browser, with a render.
tuimark dumpprints the frame and the geometry of every node,tuimark playreplays keys and shows the actions that fired, andtuimark inspectsays which rule set a property, the equivalent of the styles pane. All of it works with no terminal, which is also what makes a Tuimark view something a coding agent can build: see Use Tuimark with AI agents.
Start with Getting started for a first document, then Layout and Bindings and data.
Getting started
Install the tuimark CLI, write a first document, check it with validate, dump, preview and play, and try it live with tuimark run.
Use Tuimark with AI agents
How a coding agent such as Claude Code or Cursor builds terminal UIs with Tuimark - the AGENTS.md briefing from tuimark agents, the tuimark mcp server, the validate, dump and play loop, and the rules that keep it on track.