Interface Guidelines
How Runlet's windows, sheets, prompts, and words look and behave, so a feature built by anyone, person or AI agent, feels like the rest of the app. The rules come from the code as it is: where the code disagrees with itself, this page says which pattern wins.
Before you build a view, find the closest existing one and copy it. The names in this page point to good examples.
- New code follows this page. Old code that doesn't is listed under What Not to Copy; fix it only when your issue covers it.
- When nothing here fits, follow the macOS conventions the app already uses, and add the new rule to this page in the same pull request.
Choosing the Surface
| You need to… | Use | Example |
|---|---|---|
| Confirm something risky: code that runs on production, a run an AI client asks for, a write to a server | A sheet where Return cancels and ⌘Return confirms | ProductionConfirmationSheet, MCPApprovalSheet |
| Confirm deleting or closing something of the user's own (a profile, a connection, a pinned tab) | An NSAlert from model code, or .confirmationDialog from a view | Closing a pinned tab (AppModel+Tabs.swift), Delete Profile… |
| Report an error with nothing to choose | model.alert = AppAlert(title:message:), shown with an OK button by MainWindow | Anything that failed after the user asked for it |
| Collect a few values, or show a review step | A sheet | SnippetInputSheet, PromotedFileSheet |
| Edit a profile or a connection | A large sheet with a header, a grouped Form, and a footer | DockerProfileEditor, DatabaseConnectionEditor |
| Show details next to a control, or ask for one value | A popover | App Info, the mail chip, Logs |
| Show something live without leaving the window | A read-only popover peek | TerminalPeekView, CodePeekView |
| Tell the user about a state of the current tab | A banner above the editor | File sync, the production banner |
| Explain an empty pane or list | ContentUnavailableView | No History Yet |
| Flag a bad field | Inline red text under the field | DockerProfileEditor |
Sheets
Presenting
The model owns what a sheet shows, and the sheet follows the model:
- The pending value lives on the model (a request, a confirmation), not in
@Stateof the view that asked. - The sheet belongs to one window. A computed
Bindingreturns the value only when itswindowIdis nil or the window's own, and its setter turnsnilinto the model's cancel or decline.SchemaDefinitionSheet,SQLCSVSheets, andMainWindowshow the pattern. - One sheet at a time per window. When a sheet needs a production confirmation, the confirmation replaces the sheet's content (
SQLCSVConfirmingSheet) instead of stacking a second sheet. - Complex sheets are a
ViewModifier, attached inMainWindow.
Layout
A compact sheet, which is most of them:
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "square.and.arrow.down")
.font(.title2)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text("Import the snippets?").font(.headline)
Text("Runlet adds 3 snippets to this project. Nothing runs.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// Content…
HStack {
Spacer()
Button("Cancel", role: .cancel) { model.cancelImport() }
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("import-cancel")
Button("Import") { model.confirmImport() }
.keyboardShortcut(.defaultAction)
.disabled(!isValid)
.accessibilityIdentifier("import-confirm")
}
}
.padding(20)
.frame(width: 520)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("import-sheet")- Spacing and size:
VStack(alignment: .leading, spacing: 12),.padding(20), and a fixed width between 460 and 640. Add.fixedSize(horizontal: false, vertical: true)when the height should follow the text. - The header: a plain title is
Text(…).font(.headline). A richer one is anHStack(alignment: .top, spacing: 12)with a symbol and aVStack(spacing: 4)of a headline and a.callout,.secondaryexplanation.- Symbols: confirmations use a filled red symbol at
.system(size: 28). Informational sheets use.font(.title2),.foregroundStyle(.tint), and.frame(width: 28).
- Symbols: confirmations use a filled red symbol at
- The footer:
HStack { secondary or destructive actions Spacer() caption Cancel Primary }.- Actions such as Delete Profile… sit on the left, before the
Spacer. - A hint such as "⌘↩ confirms" or "Nothing runs: you can check the code first." is a
.caption,.secondarytext just left of the buttons.
- Actions such as Delete Profile… sit on the left, before the
- A large sheet (an editor with a form) is a
VStack(spacing: 0):- A header with padding 20 horizontal and 14 vertical, then a
Divider. - The content, usually
Form { … }.formStyle(.grouped). - A
Divider, then a footer with padding 20 horizontal and 12 vertical. - A fixed
widthandheight.
- A header with padding 20 horizontal and 14 vertical, then a
- Code and previews show in a monospaced
.calloutor.captiontext with.textSelection(.enabled), inside.padding(8)onRoundedRectangle(cornerRadius: 6).fill(Color.secondary.opacity(0.08)), with amaxHeightso long code scrolls. - Captions that explain are
.font(.caption),.foregroundStyle(.secondary), and.fixedSize(horizontal: false, vertical: true). Say what happens, and what never happens: "Nothing is sent until you review it."
Keys
| Kind of sheet | Cancel | Confirm | Escape |
|---|---|---|---|
| Ordinary (most sheets) | Button("Cancel", role: .cancel) with .keyboardShortcut(.cancelAction) | .keyboardShortcut(.defaultAction), disabled until valid | Cancels |
| Risky (production, AI client runs, a statement that writes, a dangerous database operation) | .keyboardShortcut(.defaultAction), so Return alone cancels | role: .destructive, .keyboardShortcut(.return, modifiers: .command), and a red title, Text(title).foregroundStyle(.red), on production and in DatabaseDangerSheet; a "⌘↩ confirms" caption | .onExitCommand { model.cancel… } |
| Staged edits (Apply changes to a table) | .cancelAction | ⌘Return, .buttonStyle(.borderedProminent), .tint(.red) on production | Cancels |
| Long-running work (an import or export) | Stop with .keyboardShortcut(".", modifiers: .command) | — | Ignored while the job runs |
On a risky sheet, mark the default with the comment the other ones use: // ↩ alone cancels: the safe choice is the default.
A red title is how macOS draws an alert's destructive button. .tint(.red) alone leaves a bordered button grey, and a .borderedProminent red button turns grey in a window that isn't active, as in every hidden screenshot build (#367).
Confirmations
- Model code asks with
NSAlertand gets aBoolback (runModal(), orbeginSheetModalfor a window). A view asks with.confirmationDialog(the profile editors addtitleVisibility: .visible). - The title is a short question in sentence case, with names in curly quotes: "Close pinned tab?", "Delete the SSH profile “Staging”?", "Run this code on production?"
- The message says what is touched, what isn't, and how to undo it: "The folder isn't touched.", "This can't be undone.", "Reopen Closed Tab (⇧⌘T) brings it back, pinned."
- Buttons are verbs in Title Case, never OK or Yes: Delete Profile, Remove Project, Disconnect, Save Anyway, Close. The verb comes first and Cancel second, so AppKit gives Cancel the Esc key.
- Deleting sets
alertStyle = .warningandhasDestructiveAction = trueon the verb button (role: .destructivein SwiftUI). - Unsaved changes ask Save, Don't Save, Cancel (
ProfileManager). - Don't add "Don't ask again" checkboxes to alerts. A time-limited or session allowance is a two-line
Toggleon the sheet itself, scoped as narrowly as possible: "Don't ask again for 10 minutes for this target (snippet runs only)" on the production sheet, and the session allowance on the AI client sheet. - Production keeps its warning everywhere. The explanation starts "<Target> is marked as production.", the confirm button says "… on Production", and nothing grants production a silent pass.
- Inside a popover, confirm inline in a red box instead of opening an alert over it (the mail chip's Keep Intercepting and Send Mail).
Popovers
- Info and control popovers have an explicit width (330 to 440) and padding 8 to 14. They have a headline,
.callouttext, captions, and aDividerbefore a footer row. - Small form popovers have a headline, a caption, a
TextFieldwith.onSubmit(commit), andHStack { Spacer() Cancel Primary }, with.defaultActionon the primary. - Peeks are read-only and sized to their content:
CodePeekViewis 680×360, andTerminalPeekViewsizes itself between 420×180 and 900×480. Esc closes them (.onExitCommand). Offer a way to promote the peek to the real thing, like Open in Terminal. - The edge gives the popover room:
.bottomunder header and toolbar controls,.topabove the status bar,.trailingbeside sidebar and list rows. - Closing: an action closes its popover by setting its flag to false. A binding routes
nilto the model's cancel, as sheets do. - In the editor, popovers are AppKit: an
NSPopoverwith.transientbehavior andanimates = false, which gives the keyboard back to the text view when it closes. Completion and hover info use the non-activatingPopupPanel.
Errors, Warnings, Banners, and Empty States
- An inline error is
Label(message, systemImage: "exclamationmark.triangle.fill")in.font(.caption)and.foregroundStyle(.red), with.textSelection(.enabled). Under a form field, useexclamationmark.circle.fill. Editors count what's left in the footer: "1 issue to fix". - A warning is orange:
exclamationmark.triangle.fillwith.orange. Red is for errors, danger, and production. - A failed run shows the output pane's
ErrorCard(xmark.octagon.fill, red). - A callout box in a sheet is
.padding(10)onRoundedRectangle(cornerRadius: 6).fill(tint.opacity(0.1)). - A banner above the editor is
HStack(spacing: 8) { symbol text Spacer() small buttons }with.padding(.horizontal, 10),.padding(.vertical, 6), and.background(tint.opacity(0.12)).- Its identifier ends in
-banner, and it uses.accessibilityElement(children: .contain). - It's dismissed with a Dismiss button or a borderless
xmarkwith.help("Dismiss"). - Start from
BannerinMainWindow.swift.
- Its identifier ends in
- An empty state is a
ContentUnavailableView:- The title is in Title Case ("No History Yet"), and the description is a full sentence ("This run made no requests through the HTTP clients Runlet watches.").
- Add actions where there's something to do, with identifiers. When the actions row gets cut off, wrap it in an
HStackwith.fixedSize(). - Search without results uses
ContentUnavailableView.search(text:). A failed load usesexclamationmark.triangleand a Try Again action.
Rows and Tooltips
- Never put
.helpon a whole row that contains buttons; put the row's tooltip on its label. A row-wide.help, with.accessibilityElement(children: .combine), replaces the tooltips of every button, menu, badge, and chip in the row. Put it on the name and summary, or on aGroupof the symbol and title, as the tab cards do (tabButtoninMainWindow.swift,cardinVerticalTabs.swift). - A button's tooltip starts with its action's name, as the context menu says it, then says what happens in plain words, and what doesn't: "Open in SQL Tab: write a SELECT of its first 50 rows in a new SQL tab. Nothing runs until you press Run." Leave out internals; production still asks, but the tooltip doesn't say so.
- One order for a row's buttons, its context menu (the buttons' actions first), and its named accessibility actions, most used first. Declare
.accessibilityAction(named:)in reverse: SwiftUI lists the last one first. - Buttons on hover are an option for dense explorer lists, where every row has the same few actions: the SQL tables and MongoDB collections of the Database pane. The name gets the row's whole width, and
rowHoverButtons(InspectorList.swift) lays the buttons over the row's summary while the pointer is over it, so the name never moves. The row keeps the pointer in its own@State, has the actions as named accessibility actions, and adds a DEBUG step that shows its buttons for screenshots (schema-hover:<table>,mongo-hover:<collection>). Other rows keep their buttons visible.
Settings
- Panes: each is a
Tab("General", systemImage:)holding aForm { … }.formStyle(.grouped). - Sections: use
Section("Title Case"). A section that needs a note usesfooter:with.caption,.secondarytext. - Toggles and pickers have a title and a one-line explanation:
Toggle(isOn:) { Text("Insert spaces instead of tabs"); Text("…") }. Pickers with few choices use.radioGroupor.segmented. - A setting that depends on another is
.disabled(…)and indented with.padding(.leading, 18). - Destructive actions use
LabeledContent { Button("Clear History…", role: .destructive) }and a.confirmationDialog. - Identifiers start with
settings-. - New settings go in
AppSettings, with a doc comment naming their Settings path and issue. Its tolerant decoding falls back per key, so older settings files keep loading. Experimental work hides behind aFeatureFlag(Settings ▸ Advanced).
Focus and Keys
- Rename fields take the keyboard with the whole name selected, so typing replaces it.
- Return or Tab commits, Esc cancels, and clicking elsewhere commits.
- An empty name keeps the old one, and the keyboard goes back where it was.
- Reuse
TabRenameField.
- Forms focus their first field when they appear (
@FocusStateset in.onAppear). - Inline prompts use
.focused,.onSubmit(submit), and.onExitCommand(perform: cancel). - Return is the safe choice on anything risky. Esc always cancels or closes, except while a job is running.
- Debug and hidden launches never take focus or become the key window (see Checking the App).
Wording
| What | Rule | Example |
|---|---|---|
| Buttons, menu items, commands, section headers, empty-state titles | Title Case | Close Other Tabs, Run on Production, "No History Yet" |
| Form labels, toggles, tooltips, captions, messages, alert and confirmation titles | Sentence case | "Insert spaces instead of tabs", "Close pinned tab?" |
| More UI follows (a dialog, sheet, or save panel) | End with the ellipsis character …, never ... | Rename Tab…, Delete “Staging”… |
| Something in progress | Ends with … | "Stopping…", "Reading the value…" |
| Names of the user's things | Curly quotes | the saved connection “Cache” |
| Menu paths | ▸ | Settings ▸ General ▸ Output |
| Lists in one line | " · " | PRODUCTION · Docker · shop |
| Errors | A statement without a period for the title; full sentences that say what to do next for the message. Contractions, no "Error:", no "Please", no exclamation marks | Title "Couldn't save the output", message "Choose another folder, or check its permissions." |
| Disabled commands | One sentence that names the command | "Explain Statement works in SQL tabs." |
Tooltips (.help) | A short phrase without a period. Add a period only when it's more than one sentence. A command's shortcut goes in parentheses, from model.commandHelp(_:_:) (or model.shortcut(for:)), because shortcuts can be remapped | "New Tab (⌘T)" |
| Placeholders | A short noun or verb phrase | "Filter tables and columns" |
| Who acts | Runlet is the subject, and the Mac is "this Mac" | "Runlet asks before it connects.", "the PHP on this Mac" |
| Reassurance | Say what doesn't happen | "Nothing runs now.", "Starting Runlet never runs code." |
| Spelling | American in the UI, as macOS: Color, Center | |
| Finder | Reveal in Finder | |
| Closing a sheet with nothing to save | Done |
Write UI text with contractions ("isn't", "can't", "Couldn't"); the app uses them almost four times as often as the long forms. Docs name UI elements exactly as the UI spells them.
Commands and Shortcuts
Every action the user can trigger by menu, shortcut, or Open Anything is an AppCommand in CommandCatalog (Runlet/App/Commands.swift). Menus, Open Anything, toolbar tooltips, and Settings ▸ Shortcuts are all built from it.
idiscategory.camelCase(tabs.rename,run.sqlExplainAnalyze) and never changes: shortcut overrides, What's New tours, and debug steps (perform:<id>) refer to it.titleis Title Case. UsemenuTitlewhen the menu needs an ellipsis that Open Anything doesn't ("Explain Analyze…").category,defaultShortcut, andkeywords(space-separated synonyms for Open Anything).isEnabled,disabledReason,isChecked, andcheckedLabelare closures. A disabled command shows in Open Anything only with adisabledReason.- Add a comment above it citing the issue (
// #285: …), and put it in a menu withitem("<id>")inRunletCommands. CommandCatalog.problems()(run by--self-test) catches repeated ids and conflicting default shortcuts.- A button that does what a command does runs the command, so a click can show its shortcut tip (#345):
model.perform("<id>", source: .toolbar, in: window)in the toolbar, and.buttonwithin: windoworfor: tabin the window. Palette rows pass.palette, and debug steps.script. Its tooltip ismodel.commandHelp("Name", "<id>"), with the shortcut as the user mapped it.
Visual Tokens
| Token | Use |
|---|---|
.secondary, .tertiary | Explanations, captions, metadata |
.red | Errors, danger, production |
.orange | Warnings, staging, conflicts |
.tint / Color.accentColor | Informational symbols, selection |
.headline | Sheet and popover titles |
.callout | Body text in sheets and popovers |
.caption, .caption2 | Captions, metadata, row details |
.system(…, design: .monospaced) | Code, SQL, output; .monospacedDigit() for numbers that change |
| Corner radius 6 | Boxes and callouts (4 or 5 for small chips) |
| Spacing 4, 6, 8; padding 8, 10, 20 | Rows, boxes, sheets |
.controlSize(.small) | Buttons and pickers in panes, headers, and toolbars |
.buttonStyle(.borderless) | Icon buttons in rows and headers |
| Environment badges | EnvironmentBadge: an uppercase capsule, red PRODUCTION, orange STAGING, and ReadOnlyBadge |
| Symbol | Means |
|---|---|
shippingbox | The Laravel sandbox |
folder | A local project |
cube.box | Docker |
server.rack | SSH |
exclamationmark.triangle.fill | A warning (orange) or an inline error (red) |
exclamationmark.octagon.fill | Danger |
doc.on.doc | Copy |
arrow.clockwise | Refresh, Try Again |
info.circle | More information |
Accessibility Identifiers
Every control a person can use gets an .accessibilityIdentifier. Debug steps (click:, press:, scroll:, search:), screenshot scripts, What's New tours, and UI tests find views by them.
- kebab-case,
<feature>-<element>:production-confirm,csv-export-sheet,settings-appearance. Don't add-button. - A sheet's root gets
-sheetor-confirmation, and.accessibilityElement(children: .contain). - Dynamic parts go at the end:
environment-badge-\(environment.rawValue). - Never rename an identifier that a script, a docs shot, a What's New tour (
.tourAnchor), or a test uses. Searchscripts/andRunlet/WhatsNew.jsonfirst.
Debug Steps
Each feature can be driven and photographed without touching anyone's keyboard. A new feature with UI adds its own steps:
- Create
Runlet/App/<Feature>DebugSteps.swift, wrapped in#if DEBUG. Its doc comment starts "RUNLET_DEBUG_STEPS for <feature> (#N)" and lists each step. - Write
enum <Feature>DebugSteps { static func run(_ name: String, _ argument: String, model: AppModel) -> Bool }. - Chain it from the default case of
DebugSteps.run, or from its parent feature's steps. - Name the steps
feature-verb(rename-begin), and add a<feature>-statestep that prints aRUNLET_DEBUG_STATE:line. - Add a check or screenshot script in
scripts/when the pull request needs one (scripts/tab-rename-check.pyis a complete example).
Checking the App explains how to launch the app with steps.
Shared Pieces
Reuse these before writing your own:
| Piece | For |
|---|---|
ProductionConfirmationSheet | Any run or action on production |
DatabaseDangerSheet | Any dangerous database operation (FLUSHDB, Kill Op, …) |
EnvironmentBadge, ReadOnlyBadge, TargetEnvironmentFields | Showing and editing a target's environment |
Banner | A banner above the editor |
StableInspectorList, InspectorActions, Binding.ignoringEqualWrites | Lists in the inspector and sidebars that must not jump or flicker; the header of InspectorList.swift explains the rules |
rowHoverButtons | A dense explorer row's buttons, shown over its summary while the pointer is over it (Rows and Tooltips) |
TabRenameField | Renaming in place |
CodePeekView, TerminalPeekView | Read-only peeks |
ChipButtonStyle | Small chip buttons in headers |
AppAlert | Errors |
ResizableSheet (SchemaDefinitionSheet.swift), CopyableCode (MCPViews.swift, Copy turns into Copied), and the private notice helpers are worth sharing: make them internal and move them to a shared file when a second feature needs them.
What Not to Copy
The code has older patterns that this page doesn't follow. Don't copy them into new code:
- Risky confirmations where Return doesn't cancel, whose confirm button has no shortcut, or whose red is only a
.tint. Follow Keys:ProductionConfirmationSheet,DatabaseDangerSheet(also Redis's Kill Client and MongoDB's Kill Op), the SQL server panel's confirmation, and the AI client sheet do. - Sheets in
Sheets.swiftwithout.cancelActionor a root identifier (ContainerChoiceSheet,SaveSnippetSheet,ProjectSettingsSheet). - Header symbols of other sizes (24, 26, 30,
.largeTitle): use 28 for confirmations, and.title2in a 28-point frame for information. - "Could not …" and "Cannot …" in new text: write "Couldn't …" and "Can't …".
- Sentence-case empty-state titles ("No queries", "No tab").
- "Show in Finder", "Close" or "OK" to dismiss a sheet, and identifiers that end in
-button. - Tooltips with a hard-coded shortcut ("Copy Output (⌥⌘C)" typed in the string): build them with
model.commandHelp(_:_:). - Buttons that do a command's work by calling the model instead of running the command: they count no click and show no shortcut tip. Run the command with
model.perform(_:source:in:)(Commands and Shortcuts). - Sheet padding other than 20.
- A
.helpon a whole row, with.accessibilityElement(children: .combine). Rows without buttons still have one (HistoryRow,SnippetRow,PHPInstallationRow), and it hides their badges' own tooltips. New rows put the tooltip on their label (Rows and Tooltips). - Context-menu stand-ins: the DEBUG-only popovers in
TabContextMenu.swiftandSchemaExplorer.swiftexist for screenshots. Use real context menus.
For developers
- This page and the conventions in AGENTS.md: #330. The rules were taken from the code in October 2026; when you change a convention, change this page in the same pull request.
- The docs style guide is Writing Docs; the workflow is Contributing; app checks and screenshots are in Testing.