Skip to content

The kit ​

The kit is a set of ready-made components for Tuimark: buttons, cards, alerts, a data table, a command palette, dialogs, toasts, and more. It follows the shadcn way: you do not install it, you copy what you need into your project and own it. There is nothing to import and nothing to keep in sync; if a card should look different, you edit your copy.

The kit adds no tag, attribute, or CSS property to the language. A component is ordinary .tui markup plus class names, ids, and action names that follow a few conventions, styled by two stylesheets. It lives in the repository's kit/ directory.

FileHolds
kit/theme.tcssthe design tokens, dark and light, and the base typography
kit/kit.tcssthe component classes
kit/components/NAME.tuione small, valid document per component, with the markup to copy and a NAME.sample.json beside it when it binds data
examples/kit-galleryevery component in one app, on five tabs, with a Go host

Using it ​

Copy theme.tcss and kit.tcss into your project and load them first, then your own sheet last. There is no @import in TCSS, so each document lists the sheets it uses:

tui
<tui theme="auto">
  <style src="theme.tcss"/>
  <style src="kit.tcss"/>
  <style src="app.tcss"/>
  <screen>
    <col width="1fr" height="1fr" pad="1">
      <box class="k-card" title="Deploy">
        <text class="k-card-title">api-gateway 2.14.0</text>
        <row class="k-card-actions">
          <button id="deploy__go" class="k-btn primary" label="Deploy" on:click="button_deploy"/>
          <button id="deploy__skip" class="k-btn ghost" label="Not now" on:click="button_skip"/>
        </row>
      </box>
    </col>
  </screen>
</tui>

The sheets are cascaded together, and where two rules tie the later one wins. That is how you rebrand the kit: every color is a token in :root, so your own sheet only re-declares the ones you want to change.

tcss
/* app.tcss, loaded after theme.tcss and kit.tcss */
:root { --accent: #e8590c; --focus: #e8590c; }
@media (theme: light) {
  :root { --accent: #c2410c; --focus: #c2410c; }
}

Every document in the kit validates on its own, so you can try a component before copying it:

sh
tuimark validate kit/components/table.tui --data kit/components/table.sample.json
tuimark run kit/components/table.tui --data kit/components/table.sample.json

Each component file starts with a contract, an XML comment that says what it needs from your store and your host:

text
k-NAME · Contract: <store paths it reads and writes> · Actions: <named actions> · Keys: <keys> · Requires: <other kit classes>

Tokens ​

All color lives in :root of theme.tcss, read as $name. The first ten names are the runtime's own required tokens (the same names its built-in themes define); the kit adds the rest.

TokenUse
bg fg muted border panelthe screen, body text, secondary text, idle borders, cards and modals
accent focusthe brand color, and the border or fill of the focused thing
ok warn danger infostatus colors
subtle raised seltertiary text, raised or inset fills, the cursor row of an unfocused list
on-accent on-ok on-warn on-dangertext drawn on a filled color
codeinline code

Selection and focus never rely on a background color alone. A cursor row is also bold, a focused list or table gets a double border and a solid accent bar, and a focused button is inverted and underlined, so all of it still reads under 16 colors.

Conventions ​

Classes. Every kit class starts with k-: k-card for a component and k-card-title for a part of it. (TCSS has no descendant combinator, only >, so a part cannot be styled as "text inside a card"; each part carries its own class.) A modifier is a second, short class on the same node, such as primary, ok, warn, danger, and it is only ever used next to its component class: k-btn danger, k-badge ok, k-alert warn. Pick a modifier from your data with class:NAME="path" guards.

Ids are instance__part, with two underscores: confirm__dialog, confirm__yes, confirm__no, profile__email. A node that is the whole component keeps the instance name (svc for a table), and so do the singletons of your app (app, status, nav).

Actions are component_verb, a plain identifier: dialog_confirm, palette_run, toast_show, form_submit, table_select. Generic names such as cancel or save are avoided. An action does not say which instance fired it; a host routes by the prefix of the event's source before the last __, so confirm__yes belongs to the instance confirm, and one handler serves every dialog.

Overlays (dialogs, the palette, the help) are modals that close on esc, write their own size, and declare their keys in a keymap scoped to their own class, listed before any global keymap:

tui
<keymap when=".k-dialog">
  <bind keys="y" action="dialog_confirm" label="confirm"/>
  <bind keys="n" action="dialog_cancel" label="cancel"/>
</keymap>

Sizes are explicit. A component that fills a space says so in its class, and a tabs writes focusable, so nothing depends on the default size of a bare row, col, or modal.

Components ​

ComponentClassIn one line
badgek-badgea short status label, with a modifier for its color
buttonk-btnprimary, secondary, danger and ghost, inverted when focused
cardk-carda bordered panel with a title that takes the focus color while it holds focus
alertk-alerta callout with an icon, in four status colors
app bark-appbara one-row bar docked to the top
status bark-statusbara one-row bar docked to the bottom, with filled segments
key hintsk-hintsthe keymap's labeled rows as a row of hints
confirm dialogk-dialoga modal that puts the cursor on the safe button
form fieldk-fielda label, an input, help text and an error line, with invalid styling
empty statek-emptyan icon, a title, a line of help and a call to action, centered
data tablek-tablea table with a header, right-aligned numbers and columns that hide by priority
menuk-menua navigation list with icons, shortcuts and optional check marks
tabsk-tabsa tab strip bound to a path
labeled progressk-metera label, a value and a bar in eighths
statk-stata KPI card with a change and a sparkline
toastk-toasta one-row notice that never moves the page
command palettek-palettea modal with an input over a filtered list
help overlayk-helpa modal that lists every labeled key
split panek-splita list beside a detail card, stacked on a narrow terminal
log panelk-loga scrolling log that follows its end
breadcrumbk-breadcrumbthe current path, down to the last crumb on a narrow terminal

Markup has no expressions, so a few components lean on the host: the palette's filtered list and the split pane's detail are computed with Derive, and a toast is cleared by an Every tick. The contract comment of each file says which, and the helpers below do the common ones for you.

Helpers ​

A component is markup, and markup has no expressions. Expiring a toast, filtering the palette, validating a form and routing a dialog's answer have to happen in the host. The kit ships that code as helpers: small files in Go and in TypeScript that write the store paths the components read. Like the components, you copy them and own them. They are not a package: kit/go is a nested module named tuimarkkit, with no dot in its name so that go get cannot fetch it, and kit/ts has no published name.

To use one, copy its file into your project. In Go, change package kit to your package; a helper imports only github.com/abdul-hamid-achik/tuimark. In TypeScript, copy the .ts file next to your parent; it imports only types from @thelacanians/tuimark. A file that needs another says so in its header: dialog and form need route, and palette needs filter. Each helper has a test beside it. The Go tests of toasts, form, dialog, palette and sortpage load the real component documents and drive them; the others, and the TypeScript tests, run against the store alone.

HelperWhat it doesWrites
routeInstance(source) and Part(source): the two halves of an instance__part idnothing
toastsPush(kind, text, ttl) into a capped queue that shows one toast at a time and expires themtoast.show, .text, .is_ok, .is_warn, .is_danger
filtersubstring or fuzzy match over an array of objects, by named fields{items, any, count, total} at a path you choose
sortpagecycle the sort of a column (with an arrow in its title) and paginate (1–50 of 340)the sorted rows of the page, and a meta object
formper-field rules (required, pattern, length, your own function) on change and on submitforms.<form>.<field>.error, forms.<form>.valid
dialogConfirm(instance, title, body, onConfirm) opens a k-dialog and routes dialog_confirm / dialog_cancel by instance<instance>.open, .title, .message, .result
palettea registry of commands (title, key hint, function) behind the palettepalette.commands, found, palette.open, .cur, .last
asyncLoad(path, fn) runs slow work off the handler<path>.loading, .error, .data
formatBytes, Duration, Relative, Percent, Thousandsstrings ready for {path}

The Go API has Derive and Every and the TypeScript host does not, so two things differ. In Go, filter and sortpage are derived values that follow every store write, and a toast expires on an Every tick. In TypeScript you call recompute() after a write, the palette's input fires palette_query on every change so the parent can, and toasts.start() runs the tick on a timer.

A toast queue, in Go:

go
ts, _ := kit.NewToasts(app, kit.ToastOptions{Max: 5, TTL: 3 * time.Second})
ts.Register()                       // toast_dismiss (esc)
stop := ts.Start(time.Second)       // app.Every(time.Second, ts.Tick)
defer stop()

ts.Push("ok", "Settings saved", 0)  // info, ok, warn or danger; 0 is the default time to live

The same, in TypeScript:

ts
const toasts = createToasts(ui, { max: 5, ttl: 3000 });
toasts.register();
const stop = toasts.start(500);
await toasts.push("ok", "Settings saved");

A form named by the id prefix of its inputs (profile__name, profile__email), in Go:

go
forms := kit.NewForms(app)
forms.Add("profile").
    Field("name", kit.Required("Name is required.")).
    Field("email", kit.Required("Email is required."), kit.Pattern(emailRE, "Enter a full address.")).
    OnSubmit(func(v map[string]string) error { return save(v) }).
    Reset(nil)                      // creates forms.profile.* in the store
forms.Register()                    // form_validate, form_submit

A rule is a function from the value to the message to show ("" when it is fine), so a custom rule is a plain function. Submitting shows every error and moves focus to the first invalid input.

Sorting by clicking a column header (<column on:click="table_sort">, with title="{svc_meta.titles.name}" so the arrow shows) needs the mouse on (<tui mouse="…">); for the keyboard, bind keymap rows to your own action that cycles the column.

examples/kit-gallery shows every component together, with a Go host that implements the actions and routes them by instance. See it on the Examples page, or run the view alone with fixed answers for every action:

sh
tuimark run examples/kit-gallery/app.tui --data examples/kit-gallery/sample.json --mock examples/kit-gallery/mock.json

The mock opens and closes the dialogs and shows toasts; filtering the palette, validating the form and appending to the log need the Go host (go run ./examples/kit-gallery).

Released under the MIT License.