- Use colors from the active Omarchy system theme. Do not hard-code UI colors.
- Pass semantic colors down from
App.qmlas required component properties so a theme change propagates through every view. - Derive muted, hover, and selected variants from an inherited color with
alpha, or from
Style.normalFillFor/hoverFillFor/selectedFillFor. Do not introduce literal fallback grays. - Secondary text mixes the foreground toward the background, not
Qt.darker. On a light theme, darkening an almost-black foreground makes "secondary" text heavier than body text — the opposite of what it means. tests/test_source.shenforces the no-literal-colors rule. Keep it updated rather than working around it.
Grouped by module, not by file type. A module holds whatever doing its job
takes — the rules in .js, the object in .qml, side by side. There is no
directory of "all the JavaScript": that arrangement puts a provider's parsing
three directories away from the client that calls it.
| Module | What it is |
|---|---|
| root | Service.qml, BarWidget.qml, App.qml, and nothing else. manifest.json names these three and the shell loads them at that path. |
providers/ |
Everything that differs between mail services: a description per provider, the registry over them, the protocol each speaks, and the pair of objects — signs in, fetches — that each needs. |
account/ |
One mailbox and the list of them. MailAccount.qml, Accounts.js, and the rules in Model.js about what a list does after an action. |
cache/ |
What a query result and a message body are kept in, and the two objects that keep them. |
calendar/ |
The calendars an account serves and their events: the sources in Sources.js, the rules in Calendar.js, the controller that reads and writes them, and the range cache. |
message/ |
A message's own content: parsing it (Message.js) and making it safe to draw (Html.js). |
components/ |
Views. They draw what they are given and decide nothing. |
tests/test_qml_names.pyfails on a fourth.qmlat the root, and on any QML file the Makefile does not list — a fileqmllintnever sees is a file nobody checks.- QML resolves a type by name from its own directory, so a file that builds a
type from another module imports that directory:
Service.qmlhasimport "account",account/MailAccount.qmlhasimport "../providers"andimport "../cache".
- The
.jsfiles are read by the QML engine. They start with.pragma libraryand usevarandfunctiononly — noconst,let, arrow functions, or template literals.tests/test_source.shfinds them wherever they are, so a new module is covered without being added to a list. - Everything that parses, formats, or decides lives in one of them, so the node tests can reach it without a compositor. QML holds no logic worth testing.
- One JS resource may build on others with QML's
.import "Other.js" as Other, which is howproviders/Registry.jsis assembled out ofGmail.js,Hey.jsandImap.js— and those out ofGmailApi.jsandHeyCli.jsin turn, because where a message lives on the web is a fact about the service rather than about the registry.tests/load.jsresolves the chain the same way the engine does, so the tests exercise the real files. - Tests name the module path:
load("cache/Cache.js"). A bare filename would no longer say where the thing lives.
Service.qmlis constructed by the shell itself, which injects onlyshell,manifest,pluginRegistry, andbarWidgetRegistry. It must declare no required properties: one the shell does not know about makes the whole plugin fail to instantiate, with the reason buried in a console warning.- Plugin settings reach the service from the bar widget via
applySettings, because the shell hands settings to the widget rather than to the service.
- Suffix button and menu labels with
...when activating them opens a dialog, a page, a browser, or a terminal workflow instead of completing the action immediately. - Never let colour alone carry state. Unread is a dot, a heavier weight, and a brighter subject, because some themes put the accent close to the foreground.
- Prefer the shorter label when both are honest, but never buy brevity with accuracy: "Mark these read" acts on the messages that are loaded, so it does not claim to mark all of them.
Which way a message runs is a fact about the mail, not about the window. The
interface is not mirrored and there is no LayoutMirroring anywhere;
tests/test_source.sh enforces that, because "RTL support" is the name of two
different features and only one of them is here.
- Qt already resolves each paragraph and each
Textfrom its own first strong character, and it is good at it. Leave it alone wherever it is right. It elides the logical end of a right-to-left string underText.ElideRight, resolves each<br>-separated line of a plain body separately, and needs no help with an Arabic subject or an Arabic paragraph. message/Direction.jsexists for the three places it is wrong or absent, and for nothing else. Adding a fourth caller is a decision, not a formality.- A subject is asked with
resolveSubject, neverresolve. A reply prefix is Latin whatever the thread is written in, soRe: مرحباreads left-to-right to anything taking the first strong character at face value — which is every message in a thread after the first, and most of a mailbox. - Qt honours the
dirattribute and ignores the CSSdirectionproperty.promoteDirectiontranslates one into the other next topromoteImageDimensions, which is there for the same reason onwidth. A template written for a browser states direction only in CSS. - The body's
dirand the stylesheet's physical sides are one statement. Qt readsmargin-leftand has never heard ofmargin-inline-start, so a side is chosen when the sheet is built; and Qt places a list marker on the side the block runs from, so a sheet that indents a list from the right while the block is still left-to-right does not move the bullet, it drops it. Writing one without the other was tried and looked exactly like that.baseDirectionAttributekeeps them together andtests/test_source.shkeeps them that way. - A base direction is a default, not an override: a sender's own
dir— or adirectionin their CSS, whichpromoteDirectionturns into the same thing — still wins element by element inside it. That is what makes supplying one safe on a message nobody has inspected. - A
Textwith no answer to give is left alone rather than assigned Qt's own default back.undefinedonhorizontalAlignmentrestores natural alignment; it does not warn. - A message being sent states its direction or loses it. This is the fourth
caller, and it was a decision. Qt resolves a compose field from the text in
it, so a writer sees their own paragraph against the right edge as they type
— but none of that travels with the message. A
text/plainpart states no direction at all, and a client with nothing to read falls back to left-to-right, which is what a Persian mail written here looked like in someone else's inbox.buildRawMessageasksoutgoingDirection, which reads the body by the same rule the reader uses, so a message arrives looking the way it looked while it was written. - A right-to-left body grows a
text/htmltwin; a left-to-right one grows nothing. The plain part stays exactly as it was and stays listed first, and the twin is the least markup that can carry a direction — the text escaped, its line breaks kept,diron the<body>, and no styling of any kind. It is not a rendering of the message, it is a statement about it. Left-to-right is already what a baretext/plainmeans to every client, so stating it would make every message ever sentmultipartin order to repeat the default — the same reasonHtml.jsgives a document nodirwhen nothing chose one. - A nested MIME boundary is a prefix, never a suffix.
splitMultipartfinds a delimiter by searching for--and the boundary anywhere in the body, so an inner boundary that began with the outer one would be found by the outer scan as well and the message would come apart at the wrong line.nestedBoundaryputs its tag in front for that reason, andtests/test_message.jsasserts the inner delimiter cannot be read as the outer one. - The calendar reply keeps its two parts and gains no twin. An RSVP's sentence
is generated rather than composed, so there is no writer's direction to carry,
and
multipart/alternativeholding exactlytext/plainandtext/calendaris the shape every calendar server recognises a reply in. - Qt ignores
diron a<td>— on a wrapping<div>inside one, too. Nothing is done about it: text inside a cell still resolves from its own first strong character, which covers the case that actually arrives.
- A control that opens a popup holds a selected style for as long as that popup is on screen. A trigger that looks untouched while its own menu is up leaves the menu looking unattached to anything, and leaves the user without an answer to "which of these opened it".
- Anchor a popup to the trigger's own edge, not to the pointer.
mapToGlobal(0, 0)on the control, never the click position: the menu should land in the same place however the control was pressed. - Place a popup after it opens, and again whenever its height changes. A
QQC.Popupdoes not build its contents until the firstopen(), so its height is still zero while any placement code is deciding whether it fits — the first open lands somewhere different from every one after it, which is the bug this rule exists to prevent. - A popup that would overflow flips to the other side of its trigger, then clamps to the window edge, then clamps to zero. All three, in that order.
The design and the full table are in docs/KEYS.md; read it before touching a
key. What matters while working:
- Every binding lives in
keys/Keymap.jsand nothing else describes one. The shortcut sheet and the status hints render from it, and a test assertsdocs/KEYS.mdmatches it. Three hand-written copies used to exist and had already drifted apart. - The context is the only guard. Name the contexts a key means something in;
there is no second question to answer. A text-entry context binds no bare key
but
Escape. - The context owns the keyboard: changing it moves the focus, and a context that
types into nothing parks the keyboard on a plain
Item. Never hand focus back by callingforceActiveFocus()on the focus scope — that re-elects the field being left, so it does nothing and the dismissed field keeps eating keys. focus: truemay not sit on a component that can be invisible while holding it, and a component in a context does not place its own focus — the context does. Two mechanisms for one thing is the bug this design replaces.- Route keys through
KeyRouter, never aKeys.on...Pressedhandler: a windowShortcutbeats a focused item'sKeyshandler, so a local one looks live and never runs. AnythingEscapeshould do belongs ingoBack(). - A key is not a button. A capability the provider does not declare loses
its button, but
eandsare bound in every mail context whatever mailbox is open — so the same action has to be refused inMailAccount.actbefore the optimistic update, and the status row's hints are filtered byKeymap.hintsFor's second argument. Without both, HEY — which has neither an archive nor a star — moved the row out of the Imbox and said "Archived" for a request no server ever saw. - No chords. Qt puts a deadline on an unfinished key sequence —
styleHints.keyboardInputInterval, 400ms — sogthenihalf a second later does nothing at all and says nothing about why. The mailboxes were reached that way and are numbered now. A modifier has no deadline. - A held modifier is the one thing
KeyRoutercannot own, because a modifier alone cannot be aShortcut.App.qmlwatchesKey_Altwith aKeyshandler to name the rail's rows, accepts nothing, and clears onactiveFocusrather than on the release — Alt+Tab takes the release to another window. KeyRouterbuilds its shortcuts with anInstantiator. ARepeaterbuilds onlyItems, so it creates noShortcuts at all and every key goes dead.- A
QQC.PopupwithCloseOnEscapeconsumesEscapeitself. Do not add a branch for an open popup; do add one for a plain overlay like the sheet. - An open
QQC.Popupconsumes every other key too, and that is the one place the rule above inverts. It takes the key before the shortcut map sees it —focustrue or false, bare or modified — so inside a popup aKeyRouterbinding is what looks live and never runs, and aKeyshandler on the popup'scontentItemis the only thing that works. The account switcher is the one component that answers keys itself, for this reason.tests/qml/tst_popup_keys.qmlasserts both halves, so the exception cannot be tidied back into the rule by someone who only read the rule. - The mouse does not move the keyboard's cursor. Qt re-reports hover when
content moves under a still pointer and the list scrolls to follow the
keyboard, so a hover that wrote
cursorIdpulled it back to whatever the mouse was resting on andjwent nowhere. A row draws its own hover. - The list cursor and the open message are two different things.
cursorIdis where the keyboard is;selectedIdis what the reader shows. Move the cursor withModel.cursorAfterOffset, and bring the row on screen withModel.contentYToReveal— the list is aColumn, so there is nopositionViewAtIndex.
- A mailbox is a provider:
gmail,hey, orimap, listed in that order because IMAP is the answer for a server the other two do not name and a chooser that opened with it would ask the question backwards.Provider.jsis the only place that knows the differences — which mailboxes exist, what a query string means, what the service can be asked to do, and how it signs in. Nothing above it branches on a provider id. - Two objects make a provider work: something that signs in (
AuthManager,HeyAuth,ImapAuth) and something that fetches (GmailApiClient,HeyClient,ImapClient).MailAccountbuilds one pair through aLoaderand drives them through an identical interface — same method names, same arguments, same callback shape. Adding a provider is those two files and a registry entry. - Every client hands back Gmail's message resource: a headers array, a MIME
tree, part bodies in base64url. That is what lets one list, one reader, one
cache and one set of actions serve every provider.
Message.parseRfc822is the adapter that rebuilds that shape from the wire format, and it is worth keeping even where IMAP's own structures would have been more natural. HEY never serves an RFC 822 message at all, soHeyClient.toMessagecomposes the resource from a posting and a thread read rather than parsing one — same shape, no parse. - A detail read is authoritative about what it carries and silent about the
rest, which is
Model.detailSummary. HEY reads a conversation rather than a message and so answers with no subject line of its own; replacing the row wholesale blanked the subject the list had drawn. - A capability the provider does not declare is a button the panel does not draw. Offering one that fails is worse than omitting it: it fails after the user has committed to it, with the row already moved. IMAP therefore has no "report spam" — moving a message to a Junk folder teaches a server nothing, and a button that quietly meant that is a promise the provider cannot keep.
- An account id is the address for Gmail and
<provider>:<address>for the others. One address can legitimately be more than one mailbox, and a Gmail account keeping the bare address is what stops an upgrade from having to migrate cache directories, keyring entries and the active account. - Where a message and a mailbox live on the web is a provider question, not
MailAccount's.Registry.webMessageUrlandwebBoxUrlare that seam; the Gmail call that used to sit inMailAccountwould have opened Gmail for the second provider that declared a web UI.
- HEY publishes no API, no IMAP and no POP, so the interface is
hey, the command line client 37signals ship. Do not fill that gap by driving the private endpoints app.hey.com uses, which is what this provider waited forheyrather than doing: it would ask a user for their HEY password so it could be replayed against an interface carrying no compatibility promise, and it would break on a deploy nobody announced. heyowns the whole credential. It performs the OAuth flow, keeps the token in the keyring and refreshes it;HeyAuthholds a yes or a no and the path to the program, and has nothing worth redacting. Signing out ishey auth logout, which signs the machine's HEY client out of everything that uses it — the setup page says so next to the button, because a sign-out that only forgot locally would be undone by the next status read.HeyCli.jsis the argument vectors and the answers, the wayImap.jsis the protocol: no process, no message format. Every command asks for--json, so nothing ever parses a rendered table, and the envelope'sokis the check rather than the exit status — the CLI reports some refusals in JSON and still exits 0.- A thread answers to two numbers. The posting id is its place in a box and
is what
seen,move,trashandspamtake; the topic id is the conversation and is whatthreadsandreplytake. A message id here is<posting>:<topic>for that reason, the way an IMAP one carries its folder. - Unseen is the absence of a seen. HEY's JSON omits an empty field, so a
posting nobody has read carries no
seenkey at all. There is no unseen count and no unseen box either, so the badge is a listing and a tally — which is why the unread query is the one that names a--limit. --limitand paging cannot both be had. The CLI reads pages until it has the number asked for, then truncates and drops the cursor, so a limited listing can never be continued. Ordinary listings therefore ask for no limit and take HEY's own page, and the page size the user configured is applied inHeyCli.pageOf— its token is an offset into that page plus HEY's cursor for it.- An optional flag the installed
heyhas never heard of fails the whole command, and the release most people have is older than--allow-partial.HeyClientdrops the flag the usage error names and asks again, then remembers — which is also how--htmlupgrades a HEY mailbox from text bodies to the sender's own markup with nothing here to change. Every flag dropped this way must be boolean; dropping one that took a value would leave its value behind as a positional argument. - HEY has no star and no archive, and neither is faked. A star that quietly
moved a thread to Set Aside, or an archive that filed it in Paper Trail, is
exactly the promise
Registry.capabilitiesexists to stop being made.
Imap.jsis the protocol and nothing else: every string sent to a server and every decision about what came back. No transport, and no message format — an RFC 822 message isMessage.js's subject.- The transport is
scripts/mail-transport.sh, which is curl. Fields cross to it base64-encoded on one line of stdin, so a password never reaches the process table and nothing needs escaping on the way; the config carrying it goes to curl's own stdin rather than to a file that would be on disk. - The response comes back base64 too, and that is load-bearing. IMAP measures a literal in octets. Read as UTF-8 text, 2048 octets of a message with an accent in it is fewer than 2048 characters, and the parser walks off the end of one response into the middle of the next — which is also how a message body could forge a response of its own. Base64 keeps one character per octet, so counting characters is counting octets.
- A response line has curl's 64 KiB ceiling. SEARCH returns every matching
UID on one line, so an unbounded
UID SEARCH ALLfails around ten thousand messages. Counts and non-interactive listings first take a UID snapshot withUID FETCH 1:* (UID), whose response is one short line per message, then split those stable UIDs into batches of at most 4096. An interactive search cannot wait for that whole snapshot: it reads the highest UID withUID FETCH *:* (UID)and searches one numeric UID range at most 4096 wide. If that range does not fill the page, it takes the snapshot in the background and sends every older message-bounded search on one reused connection. Never replace UID windows with message sequence-number windows, which move when another client expunges mail. - An interactive search reports its settled newest prefix. That ordering is what makes a row safe to draw before the older windows answer: nothing still in flight can belong in front of it. The numeric first window is what makes that prefix early; the snapshot fallback is what stops a sparse, long-lived mailbox from paying one TLS handshake and LOGIN for thousands of empty UID numbers. It stops once the requested page is full and keeps a next-page offset only while every preceding match is known. Counts and other non-interactive reads supply no progress callback and still scan every window, because their total has to be exact.
BODY.PEEK, neverBODY. Reading a list must not mark the mailbox seen, and that is the most common way a hand-rolled IMAP client ruins a mailbox.UID EXPUNGE, never bareEXPUNGE: the latter removes every\Deletedmessage in the folder, including ones another client marked — somebody else's mail disappearing because this one archived.- A message id is
<uid>:<folder>. A UID is unique only within its folder, so a bare one collides between folders in the list and in the body cache on disk. - Folder names are never guessed.
LISTreports them and SPECIAL-USE names them: "Sent" is "Sent Items" on Exchange and "[Gmail]/Sent Mail" on Gmail, and a client that guessed would create folders rather than find them.
- Refresh tokens go to GNOME Keyring over stdin, never through a command line.
- The OAuth client goes to a 0600 file, never to plugin settings:
shell.jsonis world-readable. - Anything that could carry a credential passes through
OAuth.redactbefore it can reach a label. - CalDAV credentials go only to the configured source's own origin. A
server-written event
hrefis resolved against the collection and refused when its scheme, host or port differs —Calendar.caldavEventUrlis the one place that decides, and the controller judges it before the keyring is touched. - Every request that crosses the network is given up on eventually, and in
QML that costs a
Timer. Qt's QMLXMLHttpRequesthas notimeoutand noontimeout: the properties do not exist, and assigning one reads back exactly what was written — so the obvious fix looks like it works and does nothing at all. ATimercallingabort()is the whole of what is available, andabort()drivesreadyStatetoDONEwith status 0, which is why the deadline needs no callback of its own: it lands in the failure path the request already had, decrementinginFlightonce. Both facts were measured against a socket that accepts and never answers, not read from a specification.tests/test_source.shfails an assignment to.timeout, because that is the mistake that looks correct; "this request has a deadline" is not something grep can ask, so it is stated here and proved by the offscreen harness rather than by a test that would pass whatever happened. - A gate that judges only the first address is not a gate.
isPostableUrlandimageSourceKindrefuse loopback, private, link-local and single-label hosts — but they judge the address the sender wrote, and Qt'sXMLHttpRequestfollows a 3xx by itself and re-sends the request, body intact, wherever that answer points. Measured, not assumed: a loopback target answering302 Location: /landedrecorded the POST arriving there. So the one-click unsubscribe goes out throughscripts/unsubscribe.sh, which is curl, which follows nothing unless told to — and a 3xx is reported as a list that did not unsubscribe rather than as an address to chase. - Qt never fetches a remote message image itself. Its loader takes no policy
from QML, follows redirects, and draws a broken placeholder while a resource
is pending. Once the reader has allowed images,
scripts/image-fetch.shfetches each approved public HTTP(S) source with curl, no redirects, a size ceiling and deadlines. Only a successful supported image comes back as adata:URI; until then the source is absent from both rich documents. Do not hand the original remote URL back to Qt or replace this with a QML request, because that reopens both redirect SSRF and the loading-placeholder defect. - Remote images in a message body are blocked until the reader asks for them. Fetching one still tells its host that this address opened this message at this moment, so tracking pixels and hidden images never enter the fetch list.
- The answer is a standing one, off until it is given. Asking per message meant answering the same question on every newsletter and remembering none of it, so the cost of asking fell on somebody who had already decided. The notice's button says "Always show", because that is what it does, and Settings is the one place that turns it back off.
- Qt is the renderer; this is the gate in front of it.
TextEditwithtextFormat: RichTextis a real HTML engine and it is what draws every message — what Qt gives a QML plugin no say over is what that engine does while it works, and it fetches<img src>, lays a<style>block's CSS out as body text, and ignoresdisplay:none. C++ could hookQTextDocument::loadResource; QML cannot. The string handed over is the only control point there is. - So it parses: tokenize → tree → clean → serialise. Not with patterns.
Where a tag ends is the one thing the image policy cannot be wrong about, and
/<img\b[^>]*>/is wrong about it the moment a sender puts a>in an alt text. - The parse is deliberately not a conformant HTML5 tree builder and must not
become one. A browser's parser inserts
<tbody>, hoists content out of a<table>and reopens formatting across a block; every one of those is a change to mail nobody asked for. This one only closes what the sender left open. - Everything downstream walks the tree by recursion, so
MAX_TREE_DEPTHis load-bearing: without it a deeply nested message is a stack overflow inside the process that draws the whole desktop. - The body cache holds the sender's HTML, not the sanitiser's output. A fix here then applies to every message already on disk instead of only to the ones fetched afterwards.
- This runs on the GUI thread of the shell that draws the user's whole desktop.
Count the parses. Opening a message is one
sanitize, and every reading of that message comes out of it: the sanitised document, the rebuilt reading document, and — when the message shipped notext/plainpart of its own — the plain text. Anything that needs to know how heavy a result is asks the call that produced it, and a view never parses a body itself. - Reading mode is a rebuild, not a filter, and that is the whole of its
security argument. The sanitiser walks the sender's tree and removes; the
reader builds a new tree and copies across text, a checked
href, a checkedsrc, and numeric image dimensions capped to the small-inline-image limit. Every element starts with an empty attribute list and only those checked values are added, so there is no path by which aclass, abgcolor, analign, astyleor abackgroundreaches the output at all. Do not keep another sender attribute: the argument is structural, and every exception has to be bounded where it is added. - A text node is not safe just because the tokenizer called it text. It goes
back out with its
<and>escaped, because this file joins text the sender had kept apart —collapseunwraps a span and welds its neighbours together, reading mode rebuilds a paragraph out of pieces and drops the characters that draw as nothing. A<that started no tag on the way in can start one on the way out, and the element it makes was never seen by the image policy, the link rule or anything else here: by then it is a string. For the same reason a style attribute has its character references decoded before it is split into declarations —url(carries the;that separates one from the next. - An HTML
backgroundis an address, not a colour. It sat in the colour list because senders write it besidebgcolor, andkeepColorstherefore let it through — a real message reached its sender's host with remote images off. Resource-bearing attributes are their own class now and are refused before the colour question is asked, because no appearance option may ever buy a network request.tests/test_source.shasserts that order. - Not everything small is hidden. A preheader is written
display:none,visibility:hidden, a one-pixel type size,opacity:0, or no height with the overflow clipped. Three near-misses are not:font-size:0on a container closes the gaps between the boxes it holds and every box re-declares a size — read as hiding, it emptied whole messages;max-height:0hides nothing unless what overflows is clipped; andmso-hide:allhides from Outlook, which makes it the version meant for everybody else, so treating it as hidden throws away the call to action and keeps nothing, because the Outlook branch is in a conditional comment the parser drops. When a reading comes out empty, suspect this before suspecting the walk. - Reading mode's link rule is stricter than the formatted view's on purpose:
mailto:or http(s) at a public host, nothing else. A message must not be able to put the machine this runs on, or the network behind the user's front door, under the pointer. Where the address is refused the label still shows — the words are the message, the address was not.
- A message body, a subject, a sender name, a snippet, an attachment filename: all of it is written by whoever sent the mail, and none of it is markup.
Textdefaults toText.AutoText, which promotes anything tag-shaped to rich text — and Qt's rich text engine fetches<img src>. EveryTextshowing message content therefore saystextFormat: Text.PlainText, andtests/test_qml_text_format.pyfails the build when one forgets.- An image is only ever fetched from a host on the public internet. Loopback,
private and link-local addresses, single-label and
.localnames,file:and relative sources are refused outright —Html.imageSourceKindis the one place that decides, and the reader, the popover and the sanitiser all ask it. - Values that go back out to Google — a
To, aSubject, anIn-Reply-Tocopied off the message being answered — lose their line breaks first, or the reply carries headers nobody typed.
- This plugin is installed by cloning it —
omarchy plugin addruns a plaingit clonewith no--depth— so everything tracked, and everything ever tracked, is between a user and a working mailbox. - Only what the plugin needs to run, plus the README, AGENTS.md and
docs/SPEC.md. Design canvases and planning notes are working material and live outside the repository;.gitignorekeeps them out. - Screenshots go to GitHub's attachment host by dragging them into an issue or a release, never into the tree. A 320 KB PNG that nothing referenced was a quarter of what a clone cost.
assets/holds what the running plugin draws, which includes the provider artwork — a few kilobytes each, at 128px, reached throughRegistry.markandRegistry.logo. Those are two different questions:markis the square icon a list row wants,logois the lockup a page about the service opens with, and a provider with one file uses it for both. They are the services' own artwork and the one thing here that does not take a colour from the theme: a recoloured logo is not the logo. A provider with no artwork draws the themed envelope instead — that is what IMAP is, a mailbox somewhere and no brand to name.- HEY is a brand word. It is upper case in every sentence;
heyin lower case is the command, and only ever appears where a command is what is meant. The program in prose is "the HEY CLI". tests/test_source.shfails on any tracked file over 128 KB. The things that get big are never the source, so the ceiling is the rule rather than a list of banned paths.
-
No scope prefix, and this is where the project departs from GPUI Component on purpose. A title is the imperative outcome and nothing in front of it:
Read a message at a readable size, notreader: Read a message at a readable size.gpui-component prefixes everything —
markdown: Share the parsed block list instead of cloning it every frame,dock: Keep a split filled when its last slot is hidden,input: Stop copying the value into InputPresentation— and it is right to. That repository is a kit of separable components and crates, so the first question a reader has is which one, and the prefix answers it before the sentence starts.This repository is one application that does one thing: mail. There is no kit, no second component, and nothing a reader has to be told apart from anything else — so the question the prefix answers never arises, and a prefix put there anyway has to be invented. That is what
app:is: a word that names nothing, on the changes that were worth doing. Do not restore the prefix by reading gpui-component and concluding the style should match. The style follows from the shape of the repository, and the shapes are different. This has been re-litigated once already. -
A title names machinery and what happened to it, in the words the code uses. It is read by somebody deciding whether this is the change they are looking for.
Use the body and the read state already on disksays which machinery moved;Open a message on what the list already knowsdescribes the mechanism that produced the result and names nothing, and reads as a title only to somebody who has already seen the diff. Prose that could sit in a release note is not a title. -
Derive the pull request title from the final
base...HEADdiff. Do not copy the first commit subject when later commits have broadened or changed the outcome. -
Re-derive it every time a commit is added, not only when the pull request is opened. The rule above is easy to satisfy at creation and easy to lose afterwards — one pull request here was opened for a single fix, grew three more, and kept the first commit's subject until somebody read it and said it was wrong. The description carries the same rule for the same reason; the title needs it said out loud because a title is short enough to go on looking true.
-
Rewrite the pull request description whenever its scope changes. It states the user-visible results, the architectural reason and invariants, and the verification actually performed; it does not preserve a chronological list of implementation attempts.
-
Commit subjects follow the same rule: imperative, outcome-oriented, unprefixed. A conventional prefix never substitutes for a precise result.
-
Markdown prose uses one source line per paragraph. Do not hard-wrap prose to a column width.
scripts/bump.sh 0.2.0is the whole of it: it sets the manifest version, commits, tags and pushes both. The release workflow refuses a tag that disagrees with the manifest, and by then the tag is on the remote and has to be deleted from it — deriving both from one argument is what stops that.- It refuses before it writes: a
vprefix, a version that is not MAJOR.MINOR.PATCH, one the manifest already carries, a branch that is not main, a dirty tree, a tag that already exists here or on the remote, and a main that is behind the remote. It runsmake testbefore tagging. - A user-visible pull request carries a
## Release Notessection in its description. Write the shipped results there as concise user-facing bullets; implementation details and verification belong in their own sections. - The release workflow finds pull requests from the commit range between the
previous and current tags, then
scripts/release-notes.shcombines every## Release Notessection and builds the complete change list. Do not replace this with GitHub's generated notes: those do not read the PR descriptions and can omit a pull request that is present in the tagged history. - The tag is the only thing that publishes a release. Nothing else creates one.
make testruns the node tests, the source regressions, and the QML tests. The release workflow runsmake test-js test-shellinstead: the QML tests need the Qt the plugin actually runs on, and a runner ships an older one that disagrees about exactly the behaviour they exercise. They are a local gate, for the same reasonqmllintis.- Run
make validateafter any QML or behavior change. It runs the node tests, the source regressions,qmllint, andomarchy plugin validate. qmllintcannot resolveqs.Ui/qs.Commonsand reports unresolved-import warnings for every plugin, including the shell's own. The exit code is the gate, not the warning count.