Hex's window, meeting capsules, transcription picker, and onboarding tour are drawn with Rune in OpenCode's design language:
rune-kitprovides the generic components, named after@opencode/ui's:Button,IconButton,Switch,SegmentedControl,Select,Menu,Popover,TextInput,TextArea,Badge,Progress, and the rest.rune-theme-opencodeprovides every colour, elevation, radius, type style, and motion token (rune_theme_opencode::kit(dark)), which is@opencode/ui's tokens as data. Hex follows the system appearance, so every view works in light and dark.
The window follows OpenCode Desktop's layout: a deep-background sidebar and a raised, rounded content panel. Hex keeps its own information, sections, and copy; the look comes from the kit.
The dictation HUD (rune_hud.rs) is outside the
system: Dot scope is a shader (rune_hud.wgsl) drawn
by a Rune overlay's compositor on every OS, hand-tuned in HUD Lab.
- Kit tokens only. Views name kit tokens through
Paint(rune_ui/controls.rs):p.k.c.*colours,p.k.ty.*type,p.k.radius.*,p.k.elevation.*,p.k.motion.*, or thePaintshorthands (text,muted,faint,accent,danger,success). The raw numbers left in views are layout metrics: row heights, column widths, and gaps. Never write a colour literal outside the theme crate. - Kit components first. Use a
rune-kitcomponent when one fits. When Hex needs more, extend the Hex piece below that does the job, or add one beside it; never keep two helpers for the same job. - One accent per screen. The kit's contrast button
(
ButtonKind::Primary) marks at most one action per screen: the thing the person came to do. A meeting capsule is its own screen. States are not actions: an ongoing state (recording, an available update) is a Secondary button with astatus_dot.ButtonKind::Dangeris for destructive and armed confirmations only; success colours mark ready and live states only. - Few visible actions. A detail header shows its one to three most common
actions; the rest go in an overflow menu, with the destructive choice last.
A destructive choice ends in "…" and keeps its two-step confirmation.
Space presses the focused button; Return presses only a
.default_button(). Make one the confirming action of a dialog, a tour step, or a recoverable confirmation once it shows, never a pane's standing action, and never an irreversible one (Clear all history, Confirm delete of a mode). - Hover is instant, and only on one target. A settings row never paints
a hover or press fill; only the controls inside it react (a row that
toggles is a
pressable_row). Hover fills belong to things that are one clickable target: list rows, picker rows, sidebar items. Every.hover(…)needs nothing extra: the window's root sets.hover_motion_within(Anim::INSTANT, Anim::INSTANT)(and each overlay window's root does too), so every hover below it, kit controls and popups included, switches with no fade either way. Never give an element its ownhover_motion. Press and selection motion stay as the kit draws them, except in lists: alist_rowor list + detailitemselects on the pointer's press with no press overlay, and the selected fill and the detail swap in that frame (select_at_once). - Verify by looking. Render every view you touched with a
--snapscene in light and dark (see Verification).
These live in rune_ui/controls.rs and
rune_ui/lists.rs and compose kit components:
| Job | Piece |
|---|---|
| Settings page (the gutter and the rhythm between sections) | page (cards hang row_px past the gutter so row text sits on the title's edge) or bare_page (free-standing content on the gutter); their numbers are METRICS, the one set of layout tokens |
| Settings card, titled card, a card whose label line carries a control | card, section, section_with (Advanced, a status, an add button) |
| Settings row (title, description, control) | setting_row, described_row, link_row (a one-line link under the title, at the plain row height), toggle_row; a row that toggles shows row_switch; any other row pressed as a whole: pressable_row |
| A few exclusive choices / a dropdown | segmented, select |
| A value whose choices open in a sheet, not a menu | sheet_button (the value and a trailing chevron) |
| Main, other, and destructive actions | button(…, ButtonKind::{Primary, Secondary, Danger}, …); inside a row: small_button; icon only (with an accessible label): icon_button |
| How a modal enters and leaves | modal_transition on the surface (scale 0.96 and fade), modal_scrim_transition on its scrim |
| Shortcut keycaps | keycaps (Small keys in a recessed tray as tall as a recorder); one live key that sinks when pressed: keycap inside key_tray; the recorder is settings::hotkey_control |
| Status (never clickable) | tone_tag; a live or recorded state: status_dot |
| Short message under a control | note |
| Text entry saved when typing pauses | text_field |
| Keyboard highlight in a search popover | Highlight |
| List + detail (Commands, Meetings, History) | list_detail: list_column (search over the rows, on the title's edge, no divider) and item rows (no hover fill, time on the right, a hover action in its place via row_action), grouped by group; the selected item in body's raised card with card_header, card_title, meta_text, ghost_icon actions, card_scroll, and facts; empty_state for nothing to show |
| List + detail (Activity) | split with list_row, group_label, detail_header, detail_title, section_label, fact |
| Keyboard-selectable list (sidebar, list + detail, picker languages) | source_list around rows made with list_row or source_item |
| Nothing to show, errors | message, placeholder, error_panel |
| A permission's Allow / Allowed control | permission_control, allowed |
| Slider | slider |
Pane-specific pieces stay in their pane module: the listener card and model
cards in rune_ui/settings.rs and
rune_ui/picker.rs, the onboarding art in
rune_ui/onboarding.rs, and the capsules in
rune_ui/meeting_capsules.rs.
platform::Capabilities decides which panes and rows exist on each OS; a
missing capability omits the row instead of imitating it.
- Sentence case for every label, button, title, and section label: "Open settings", "Stop & transcribe", "Permissions needed". Proper nouns keep their capitals: Hex, OpenCode, Apple Intelligence, Finder, Zoom.
- One product name: "Hex", in copy, comments, and docs alike.
HEX_*environment variables are identifiers, not copy. - Use "…" (one character) for ellipses, for both progress ("Saving…") and for buttons that open a further choice ("Choose a file…").
- Row descriptions are one phrase with no closing period ("Hold to dictate, release to transcribe"). Two sentences keep their periods.
- Keep one direct line per control. Don't add intro paragraphs above cards, repeat field labels per row, put state words next to switches, or warn "cannot be undone" beside a two-step confirmation.
- Every translated string (
tr,tr_fill,tr_named) needs all five languages indesktop_i18n.rs;every_translated_string_has_every_languagescans each UI source. Developer-only panes (Activity, HUD Lab) stay English. - Exception: native macOS menus (
status_item.rsand the app menu inmacos_rune.rs) follow the macOS title-case convention ("Check for Updates…", "Paste Last Dictation", "Stop & Transcribe").
# Headless renders of named fixture scenes (`SCENES` in src/rune_ui.rs):
cargo run -- app -- --list-scenes
cargo run -- app -- --snap /tmp/hex-modes.png --scene modes
cargo run -- app -- --snap /tmp/hex-modes-light.png --scene modes --theme light
cargo run -- app -- --snap /tmp/hex-mac-settings.png --scene mac-settings
# Both themes of several scenes into one folder:
OUT=/tmp/hex-snaps scripts/snap-set.sh modes mac-settings
# Goldens (light and dark) and pane behavior:
scripts/check rune_ui
# After an intended visual change, rewrite the goldens (Linux only):
HEX_UPDATE_GOLDENS=1 cargo test --locked --bin hex rune_uiOn a machine without a GPU, render on lavapipe with
VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json. Goldens live in
tests/golden/ and are compared within a small per-pixel tolerance. The golden
tests run only on Linux; from a Mac, dispatch check-linux.yml on the branch
and take its golden-actual artifact. Look at every changed image and commit
it with the change that caused it. A scene is a fixture, not native end-to-end
proof: the meeting capsules and the HUD have no scene, and the real window,
overlays, and input still need a person on each OS.