Widget catalogue
There are two catalogues, because there are two shapes of package. A pipeline declares controls and the host infers the widget from an inference table. A full application declares widgets by name from a fixed catalogue.
Pipelines
Inferred, then overridden.
A pipeline does not name a widget. It declares a semantic type, and the host infers the widget. This is normative: every host of .hutash manifests should produce equivalent default widgets for the same semantic types.
| Control type | Condition | Default widget | What it renders |
|---|---|---|---|
| number | min and max set | slider | A horizontal track with a numeric readout beside it. |
| number | no bounds | number_input | A numeric field you type into. |
| number | integer: true, no bounds | number_input | The same, whole numbers only. |
| enum | 5 options or fewer | pills | A row of buttons, all options visible at once. |
| enum | more than 5 options | select | A dropdown. |
| enum | multiple: true | tag_select | A chip list you add to and remove from. |
| string | multiline: true | textarea | A multi-line box. |
| string | otherwise | text_input | A single-line field. |
| boolean | — | toggle | A two-state switch. |
| file | otherwise | dropzone | A drop target that is also a click target. |
| file | multiple: true | multi_dropzone | The same, accepting several files. |
Non-graphical consumers — a CLI, a tool surface, an API client — ignore this table entirely and use only the semantic type and its properties to validate input.
Any control may carry an optional ui: {widget: <name>} block that overrides the inference. The block is advisory: deleting it from any manifest must not change the correctness of any invocation, only its presentation. An unrecognised widget name is ignored and the control falls back to its inferred default, so a manifest authored for a future host still renders on an older one — less precisely, but correctly.
Widgets that need an explicit override
Ten registered names are the inference table's own outputs and need no override: slider, number_input, pills, select, tag_select, textarea, text_input, toggle, dropzone, multi_dropzone. Seven more must be asked for.
| Widget | Parameters | What it renders |
|---|---|---|
| color_picker | the control's own (string, a hex default) | A colour swatch and picker for a hex string. |
| point_selector | the control's own (string, an "x,y" default) | A crosshair placed on an image. |
| range_pair | two number controls | A two-handle range. A compound v0 range-pair control splits into two number controls carrying this override. |
| image_preview | the control's own | A preview of an image value. |
| waveform | the control's own | A waveform drawing of an audio value. |
| code_editor | the control's own (string, multiline: true) | A monospace editor with the affordances of one. |
| option_picker | an enum whose options use the object form | A filterable card or grid picker that groups by the metadata each option carries — accent, gender, quality. This is what Kokoro's 28-voice control uses; select and pills render flat options only. |
How a control maps outward
Mechanical and complete — a tool surface can be generated from any manifest with no human step.
| Control type | Tool-schema type | CLI validation |
|---|---|---|
| number | {"type": "number", "minimum": …, "maximum": …} | parse as float or int, check min/max/step |
| enum | {"type": "string", "enum": [...]} | check membership in options[] |
| string | {"type": "string", "maxLength": …} | check max_length |
| boolean | {"type": "boolean"} | accept true/false, 1/0, yes/no |
| file | {"type": "string", "description": "File path"} | check the file exists and its extension is in accept[] |
Pipeline types
These describe what flows into and out of a model, which is what makes chaining possible without hardcoding model-to-model connections. They appear only in inputs: and outputs:, never in controls:.
| Type | Typical format | Metadata properties |
|---|---|---|
| text | UTF-8 string | max_length |
| audio | wav, mp3, flac, ogg | sample_rate, channels, max_duration |
| image | png, jpg, webp | max_width, max_height, color_space |
| video | mp4, webm, mkv | max_duration, max_fps, max_resolution |
| subtitle | srt, vtt, json | formats |
| document | pdf, docx, txt | max_pages |
| embedding | JSON array, numpy | dimensions |
| segments | JSON | — |
New pipeline types may be added; a consumer meeting an unknown one must treat it as opaque pass-through data rather than failing. New control types may not be added without a spec version bump — the five are a closed set, and anything that appears to need a sixth is a string or an enum with a widget override.
Applications
The vertical catalogue.
Twenty-nine widgets. A vertical composes its whole interface by naming them in vertical.yaml; it writes no interface code. Every prop listed is one a widget actually reads — a prop not listed is ignored.
"Context" is what a widget reads without being told: workflow (the form values and run state), media (playback position and the segment list), navigation (the current page), autosave (the open project's save state). A page carrying any media widget gets the media context automatically; autosave requires an open project.
The control vocabulary
Not preferences. Pick by the shape of the choice, not by taste, so the same question looks the same in every application.
| The choice | Control | Why |
|---|---|---|
| Yes / no | toggle | One bit, one control. A two-option dropdown hides half the answer behind a click. |
| One of few (2–3) | card selection | Few enough to show at once; seeing all the options is the value. |
| One of many (4+) | dropdown | Too many to lay out without crowding the page. |
| An action | action_button with a variant | An action is a verb, not a value. |
action_button variants: primary — the one thing this page is for, at most one per view; secondary — a real alternative to it; ghost — tertiary, present but not competing; danger — destructive and hard to undo. A page with two primary buttons has told the user nothing about which one to press.
Inputs
Input widgets.
Every input widget takes bind: — the workflow input it fills. Widgets seed what they display: a select shows its first option whether or not the form holds a value, so every choice widget writes its displayed value into the form on mount, and never overwrites a user's choice.
| Widget | Props | Behaviour |
|---|---|---|
| file_upload | label (string, "Drop a file here") — zone text and its accessible name · accept (string[]) — MIME patterns, e.g. [video/*, audio/*] · multiple (boolean, false) | A drag-drop zone that is also a click target. Binds a file; a run carrying one is sent as multipart automatically. |
| dropdown | label · options ({value,label}[]) · options_from (workflow:<id>.inputs.<input> or api:<path>) · prepend — fixed entries above whatever loads · default · filter_by ({field, from}) · describe_by (string[]) | One-of-many selection. Options resolve from three sources so a vertical never restates a list that already exists, and an option keeps every field its source declared. A source that fails to load yields the prepended entries alone: a picker offering less is usable, a page that refuses to render is not. |
| text_input | label · placeholder · multiline (boolean, false) · max_length · default · autofill_from | Single or multi-line text. autofill_from: media_file turns my_holiday-clip.mp4 into my holiday clip. It is an offer: it fills an empty, untouched field only, so a typed name survives swapping the file. |
| number_input | label · min / max · default | A numeric field. Use it where the exact figure matters more than its position in a range. |
| slider | label · min (0) · max (100) · step (1) · default (min) | A bounded numeric range. Shows its current value beside the track; a slider you cannot read a number off is a guess. |
| toggle | label · default (boolean, false) | An on/off switch, rendered as a role="switch" button. The control for every yes/no. |
| model_selector | label · capability — filters to models declaring it (stt, translate) | A model picker filtered by capability. Lists models from the engine, disabling any not installed and marking them so. An empty value means "Automatic" — the app resolves a model at run time. |
Why describe_by and filter_by exist. "Hindi" says everything and "Fenrir" says nothing, so describe_by appends option fields after the label, em-dash separated, in the order given, skipping a field an option does not carry rather than leaving a dangling comma. filter_by exists because two questions' answers are not always independent: a voice selects the phonemizer, so an English voice given Hindi text returns a well-formed audio file of a fluent-sounding voice saying no words — the run succeeds, nothing warns, and only a listener can tell. Two deliberate tolerances: an option that does not declare the filter field is kept rather than hidden, so a manifest predating the field does not vanish from the picker; and a held value the filter no longer offers is replaced with one it does, because leaving it produces a form that looks correctly filled in while the run receives something the user never saw.
Media
Media widgets.
These share one media context — playback position and the segment list — which is what lets a player, a transcript and a waveform cooperate without knowing about each other. The page's YAML decides which are present.
| Widget | Props | Behaviour |
|---|---|---|
| video_player | show_subtitles (false) · subtitle_style (classic) · style_input ("style_id") · sources ({id,label,output?}[], []) · autoplay (false) · loop (false) | An HTML5 video with controls and a styled caption overlay. Plays the media file from the form as an object URL; the file never leaves the browser for playback. |
| audio_player | show_waveform (true) · show_volume (true) · height (120) · show_segments (true) · draggable (false) | Waveform-led playback and transport, for media with nothing to look at. No video element, and so no black rectangle where the content should be. The waveform is waveform_timeline, rendered by this widget rather than drawn a second time; this widget owns only the audio element that actually plays. |
| output_player | output (first declared file output) · label ("Result") · media (audio or video, inferred from the format) | Plays a file the run produced rather than the file the user uploaded. A text output is never a candidate — without a file there is nothing to fetch. Before any run it says "Nothing generated yet." rather than drawing a transport that does nothing, and a run whose files are gone says so. |
| source_switcher | label ("Showing") | Chooses which of video_player's declared sources the main player shows. It declares nothing else: the options come from the player's own sources: block, because restating them would put one fact in two places in a single YAML file. Fewer than two cuts renders a message rather than a one-card control. |
| hanu_panel | label ("Hanu") · placeholder ("Ask about this project…") · empty_text (built in) | Hanu, the assistant: chat that can see what the app has open and can act on it. The context is read, never passed in, and a badge at the top names what it can see. |
| media_meta | show_duration (true) · show_date (true) · fallback_title | Title, run length and date under a player. Matters most where a player cannot help: audio, with no frame to recognise the file by. A missing fact is omitted, not rendered as a dash or a zero — "0:00" beside a file still decoding is a wrong answer. With no title and no facts it renders nothing at all. |
| subtitle_list | editable (true) · show_timestamps (true) · click_to_seek (true) · follow_playback (false) · auto_save (true when editable) · save_path (transcripts/subtitles.srt) · style_preview (true) · style_input (style_id) · save_endpoint · working_path · platform_input (platform) | Editable timed text, auto-saving. Click a timestamp to seek, click text to edit; Enter commits and moves down, Tab commits and jumps to the other field, Escape cancels. Auto-save debounces edits for 2s, then writes, and does nothing at all when there is no project. |
| waveform_timeline | show_segments (true) · draggable (true) · click_to_seek (true) · height (80) | An audio waveform with each cue laid over it as a draggable region. Dragging a region edge writes the new timing back to the shared segments, so the subtitle list updates with it. Segments with no text get no region — a gap is bare waveform, and drawing a region over it would claim a cue is there. Degrades to a placeholder where the audio API or a canvas is unavailable rather than throwing. |
| style_picker | styles_from (/styles, api: prefix optional) · default (first style) | A list of caption-style names. Clicking one restyles the overlay on the player instantly; hovering previews without committing, and leaving the row puts the chosen style back. Keyboard focus previews as hover does. The chosen style is not cleared when the widget unmounts: it belongs to the project, not to the panel that set it. |
The caption overlay is the style preview. Choosing a style swaps a class on the overlay and nothing else happens: same words, same frame, same moment, different look, instantly. Nothing is wired between the picker and the player — the style id travels in the media context. It resolves in falling order of authority: the live choice in context, then the bound workflow input named by style_input, then subtitle_style. The input matters because the picker usually lives in a tab and only the active tab is rendered, so reading the input directly is what puts yesterday's style on the captions at first paint.
Six styles are drawn: bold_pop, word_center (one word at a time), karaoke (whole line, spoken word lit), two_line, minimal, classic. An unknown id falls back to classic rather than vanishing, and a word-by-word style falls back to the whole line when the segments carry no word timings. These are approximations of what the encoder bakes in — sizes and positions are proportional to the same 1920×1080 reference the export is authored against, so the preview answers "is this the look I want" honestly; the export is the artefact, rendered once, on the style the user settled on.
An entry in sources naming an output is fetched from the run through the client (bearer token in a header, never a query string) and played from an object URL; an entry without one is the uploaded file. The player owns this list and publishes it, so a source_switcher in a side panel needs no copy of it. A named cut that cannot be fetched says so where the picture would be — it does not fall back to the original, because showing someone the source video and calling it the dub is worse than showing them nothing.
A vertical may own its own save format. The catalogue's write path takes text: a widget encodes its content and the project stores the bytes. That is right for SRT and wrong for anything that has to be generated, so save_endpoint hands the route the segments themselves — words intact — plus the style, the platform, and the SRT the widget encoded. {project_id} in the path is substituted. working_path names the file to read back, tried before save_path: SRT can say only when a cue starts and ends, so a project reopened from one lost its per-word timings and its style, and every animated style collapsed to one static cue per line. With neither prop the widget saves text to save_path exactly as before, and a missing, unparseable or foreign working file falls back to it — which is what opens every project made before the format existed.
Cues in the list are drawn in the chosen caption style, because a caption style is a property of the subtitles rather than of the player showing them — the look only (weight, letter case, the plate behind classic), not the size and not the outline, both of which need a 16:9 frame with a picture behind them. The row being edited is never styled. follow_playback never scrolls while a row is being edited.
draggable defaults false on audio_player and true on waveform_timeline, and that is the difference between them: retiming a cue is a subtitle-editing gesture, so place waveform_timeline directly when editing timing is the point, and audio_player when playing is. Similarly output_player is deliberately not a flag on audio_player: that widget owns the single audio element attached to the media context so the transport, the waveform and the subtitle list read one clock, and a generated voice-over is a different recording on a different clock. The absence of the shared clock is what output_player is for.
Hanu reads its context. The open project comes from the workflow inputs, the transcript size from the media context, the current page from the navigation context. A widget that had to be told what the user was doing would be told wrongly the first time a page was added, and a model grounded in the wrong project answers fluently and incorrectly with nothing on screen to contradict it. The system prompt is built on the backend, where the list of this app's workflows already exists. When the model names a workflow the app really has, the reply renders a button that starts it through the same run path every other control uses; a named workflow the app does not have is dropped server-side rather than rendered, because a button that cannot work looks like a feature. Memory-less: each turn is sent alone, and a failed turn is reported beside the transcript rather than inside it, because the model did not say it.
Display and layout
Structure widgets.
| Widget | Props | Behaviour |
|---|---|---|
| section_header | title ("") · action_label — omit for a plain heading · any action_button action prop | A heading with an optional action. |
| status_badge | status ("unknown") · label (the status) | A state pill. Four states: installed, complete, compatible, ready map to success; running, degraded, pending, progress to warning; failed, error, incompatible to error. unknown is a real state — something the engine has not classified — not a placeholder to colour green. |
| progress_steps | visible_during — workflow_run hides it until a run starts | Step-by-step run progress. Collapses repeated events for one step onto that step's own row, so a step updates in place instead of appending. |
| toolbar | items (ToolbarItem[], []) — each label, icon, tooltip, action, to, or separator: true | A row of actions and separators. Supported actions: navigate, run_workflow, save. action: save renders a stateful control — Saving… / Saved ✓ / Save — that flushes pending work immediately; it disables itself when nothing is pending, because a Save button that writes an unchanged file teaches the user that pressing it means nothing, and renders nothing when there is nowhere to save. |
| sidebar | items (SidebarItem[], []) — each label, icon, action (navigate default, or save), to · label ("Sections") · expanded (false) | A collapsing icon rail of destinations. icon names one of the rail's drawn glyphs — folder, marketplace, library, grid, gear, disk, mic, doc, list, save_to_disk, chevron_left — never an emoji, which renders differently on every platform and cannot take the rail's colour. An unknown name falls back to grid. A page carrying a sidebar loses the top bar's page buttons, keeping only the breadcrumb. Give the column left_width: auto; a fixed width clips the labels when the rail expands. |
| tabbed_panel | tabs (Tab[], []) — {id, label, widgets} · default_tab (first tab) · label ("Panels") | Several panels of widgets in one column; each tab holds widgets resolved through this same catalogue. Only the active tab is rendered — not hidden, absent, with no element in the DOM. An inactive tab loses its widgets' own local state but not the work: a transcript's edits live at page level, and a pending debounced save is flushed on unmount. |
| save_status | none | Page-level save state, for a page that edits without a widget reporting its own. Stands down automatically when a widget is already reporting, so it never doubles up. |
Reach for tabbed_panel instead of one overlay per panel when the panels are about the thing on screen: an overlay covers its own subject, and a caption style you cannot see against the video while you pick it is a style you pick twice. An overlay is still right for a panel that is not about the page underneath it — Settings and Models are the same wherever you opened them from. If you add a tab whose state must outlive a switch, lift that state to the page rather than making the panel persist.
Page-scale
Whole-page widgets.
| Widget | Props | Behaviour |
|---|---|---|
| project_grid | opens ("editor") · create_opens ("new_project") · create_button (true) · show_thumbnail (true) · show_status (true) | A card grid of projects, responsive by default — the widget owns that, so no app declares columns. Cards navigate carrying the project's id and name, which is what gives the editor a save destination. Right-click a card for "Remove from list" — no prop, every grid has it. It unlinks the project marker and nothing else: the folder, the media and every export stay on disk. Deliberately no confirm dialog, because nothing is destroyed; the menu carries "Files stay on disk" instead. |
| models_page | capability_labels ({capability: label}, {}) — human names, e.g. stt: Transcription | The marketplace, grouped by the capabilities the manifest declares and filtered to the vertical's capabilities_needed. Install and remove proxy to the engine and then re-read the list, because the engine owns install state and a local guess is wrong the moment an install fails. Install progress is polled from the engine's own activity feed and never mirrored; a null percentage renders an indeterminate bar, never a fabricated figure. |
| setup_guard | none | The first-launch gate. What it requires is derived from manifest.yaml's capabilities_needed and refined by vertical.yaml's setup: block. A vertical is a shell around models it does not ship, so with none installed every workflow fails at the engine — and the failure arrives where the user can least act on it, mid-run, as a proxy error naming a capability. This asks first. It is a gate, not a settings panel. The app's own shell mounts it automatically; placing it on a page is for a vertical that wants it as a page of its own, and it renders nothing when there is nothing to install. |
| settings_page | title ("Preferences") · settings (defaults to vertical.yaml's settings:) | The settings form, generated from the settings schema. |
Set create_button: false when a section_header already offers "New Project" — two controls with the same label is duplication. On a model card, six fields are read from the models endpoint: compatibility (a dot when compatible, a pill when degraded or incompatible), compatibility_note (its tooltip — the reason, "Needs 8GB, your GPU has 3.6GB usable"), hardware (what the catalogue says the model needs), license, size_mb (absent when unknown), and rating (a score out of 10, rendering nothing when there is no score, because a dash is a fact the app does not have dressed as one it does). hardware and compatibility are both shown on purpose: one is a claim about the model, the other a verdict about this machine, and either alone leaves the user unable to tell an unsupported model from an unsupported computer.
Actions
Action widgets.
| Widget | Props | Behaviour |
|---|---|---|
| action_button | label ("Run") · running_label (label) · variant ("primary") · block (false) · action (run_workflow or navigate) · requires (string[], []) · workflow · routes (Route[]) · create_project (${inputs.<id>}) · then_navigate_to · navigate_params · to / params | The page's primary action. Disabled while running and while any requires input is empty, so the button is never a promise the run cannot keep. Inputs are filtered to the chosen workflow's declared ones, so a form value the workflow has no parameter for is never sent. routes lets a form ask a question the user understands ("what language?") and pick the workflow from the answer, so the user never picks a workflow by name. create_project records the project before the run, so the work has somewhere to live from the moment it exists. |
| export_controls | formats ({value,label,output?,extension?,filename?}[], [txt]) · filename (output id) · preview_lines (8) · save_folder ("exports") · style_input ("style_id") · platform_input ("platform") · styled_format ("mp4") · output | A format picker, a preview, and a save into the project. preview_lines sizes the box; it does not truncate the content. |
What an export writes. It exports what is on screen — the edited segments, not the raw run output — for the output those segments are. The chosen format's own output decides first, then the panel's output, then the workflow's primary: output, then the first produced. The edits reach only a subtitle-editor output or one whose value is already timed text: handing them to every output wrote Podcast's transcript into a file named after its show notes, and left the notes unexportable.
One action, and it writes to disk. An export lands in the open project's folder; nothing is handed to a browser to drop in a downloads folder, separated from the project it belongs to. The button says what it does — Save for a text format, Render video for mp4, which is minutes of encoding and does not pretend to be instant. Neither says "download"; the word survives in the catalogue only where something really is fetched over a network, which is a model download. Afterwards the panel says where the file went and offers Open file and Open folder, which go through a backend endpoint because a vertical's interface has no route to the desktop shell.
A format carrying output names what it carries. When that output is a file the run already produced, it is kept as it stands; when it is text, that text is what gets serialized — which is what lets one panel offer every deliverable of a run instead of only the transcript. With a style chosen, the video render is animated: the style, the platform and the word-level segments go with the request and the backend builds a styled caption document from them, with the SRT riding along so a backend that does not recognise the style falls back to a plain burn-in. styled_format then leads the list and starts selected — reordered, not filtered, and followed only once, so someone who deliberately switched back does not have the choice taken away again.
Persistence
Remembering an input.
persist: true on any bound widget stores its value against the open project and restores it when that project is opened again.
Opt-in, per input, never global. Most inputs must not be remembered: a form reopening pre-filled with the last run's file is an offer to redo work, and one that runs against the wrong video if nobody looks. What qualifies is a decision about the project rather than about the form — a caption style, a target platform. Values live in the project's own marker file, so they travel with the folder; only strings, numbers and booleans persist. A persist: true input is spared by reset_inputs either way, because it was restored from disk and clearing it would discard a choice the user never revisited.