Written: 2026-08-10 · applies to every surface in src/renderer, to everything shipped
out of this repository (rule 15), and to the manual inside the app (rule 16)
These are not style preferences. Each one is here because it was broken, found by using the app, and cost real confusion — the offending commit is named under each rule so the next person can see what the failure actually looked like.
The theme running through all of them: the app must never be the last to know what it just did.
Writing to the database changes more client state than the row you touched. Before shipping a mutation, list what is now stale and refresh all of it.
For an idea, that list is:
| What | Goes stale when | Refresh |
|---|---|---|
| The row | any edit | patch locally — the value is in hand |
View counts (ideas.counts) |
status or favourite changes | refetch |
| Page membership | status changes and status is a filter | reload() |
| The pager total | anything leaves or joins the filter | comes with reload() |
| The build queue | the idea is deleted (cascade) | queue.list() |
| The bin badge | delete or restore | recycleBin.list() |
Found by: marking an idea built left the Built tab showing its old
number until the user changed section. handleIdeaUpdated patched the row and
nothing else. Same function also left the row visible on a page whose filter it
no longer matched.
Found again, next to it: build_queue.idea_id REFERENCES ideas(id) ON DELETE CASCADE (migration 013). Deleting a planned idea drops its queue row in SQLite,
and nothing in the renderer refetched the queue — so the rail badge kept counting
a card that no longer existed.
A cascade is a mutation you did not write, and it invalidates client state exactly like one you did. Grep the migrations for
ON DELETEbefore deciding what a delete touches.
A control that closes on the value you picked has proved it registered the
click, not that it wrote anything. When the visible result of an action is
subtle, off-screen, or identical to the state before it, write to notice:
GITIN-004 is now built.
MLTOO-002 moved to the recycle bin.
Actions whose result is visible need no sentence — starring a row repaints the star, planning one flips the button. Announce the ones that do not.
items.length === 0 means two opposite things: you have nothing and I have
not looked yet. Rendering the empty state during a fetch tells a user with 44
ideas that they have none, and then takes it back.
Every list needs an explicit loading flag, starting true, cleared in a
finally so a failed query stops claiming to be loading.
- Skeletons only when there is nothing else to show. While paging, keep the
previous page up until the new one lands. Swapping ten real rows for ten grey
ones for the 2 ms a
LIMIT 10takes is a flash, not feedback. - Skeletons match the row they replace — same gutters, same line count — so nothing resizes when the data arrives.
Found by: Discover rendered "Nothing here yet" on every cold start.
React unmounts the whole root on an uncaught render error: the window goes black
and the only way back is a restart. Wrap each independently-failing region in
ErrorBoundary, keyed on whatever navigates it, and keep the navigation
outside the boundary — leaving a broken section requires the rail to have
survived.
Found by: LogsPanel called .toLocaleString() on a token count that was
NULL for 41 of the first 104 rows. Opening Settings → Logs killed the app,
every time.
A count the provider did not report is not 0. A NULL token count means
usage was not reported; rendering it as zero claims a 40-second synthesis cost
nothing. Show — and put the reason on the tooltip.
Corollary from asArray in validate.ts: when adding a stage, make "produced
nothing" a distinguishable, logged outcome. A silent zero is worse than a
crash.
- Do not overstate. "Delete" for an action that moves a row to the recycle
bin, keeps its specs and notes, and is reversible for
recycleBinDays— that makes a safe action sound final, and wears out the word before something genuinely unrecoverable needs it. It reads "Move to recycle bin". - Reassure by naming what survives, not by denying destruction. "The idea stays in your library with the same status" beats "the idea itself is not deleted" — a reassurance phrased as a denial has to be read twice, and it introduces the alarming word into a dialog that had no business raising it.
- Do not understate either. A filled "In the plan" button that silently removes on click is a trap. State stays on the button, the consequence goes on the tooltip, and the confirm dialog spells it out before anything happens.
An earlier "In the plan" swapped its own label to "Remove from plan" on hover. Legible, and restless: the control is a readout most of the time, and a readout that changes wording whenever the pointer crosses it stops being readable as one. It also had to be sized for the longer label it usually hid.
The label holds; the surface answers. A step darker under the pointer says
"pressable" without saying anything false. Use brightness rather than a second
set of colour tokens — it darkens whatever the fill currently is, so one rule
covers both themes and there is no near-duplicate hover colour to keep in step.
"In the plan" was disabled. The only way to undo a mis-click was to leave for
the Build plan workspace and find the row. A control that only goes one way is a
label wearing a button's clothes. Disable it only where the state is genuinely
irreversible — here, built and rejected.
Add aria-pressed so it is one toggle to a screen reader rather than two
buttons that happen to look alike.
An outline star that only changes colour on hover says "this is a button" without saying what pressing it does. The interior now fades in at 55% — enough to read the intent, far enough from solid that it cannot be mistaken for a row that is already a favourite. The preview uses the same colour as the result, because it is a rehearsal of it.
Carried over from the Settings work, and re-earned since:
externalVersion— the model manager mutates the machine and closes; Settings went on rendering the list it read on mount.onSettingsChange— Settings' own writes were invisible to the app.- Provider health — probed once at startup and re-probed only when someone
pressed Test, so switching Ollama → Gemini left the status bar naming the old
provider, with the old model, and a reachability verdict about a service the
app had stopped using.
patch()now re-probes onprovider,geminiModel,ollamaModelandollamaHost.
Re-probe rather than assume. A provider that is selected is not necessarily a provider that answers, and saying which is the indicator's entire job.
The status chip carries the model, then CPU/RAM/VRAM meters. On a cloud provider the GPU is not what answers your requests, and a VRAM bar that never moves during generation teaches the wrong thing about what generation costs. VRAM leaves the chip on Gemini and stays in the panel under Load, where it is labelled as machine state.
Same rule retired the Loaded in VRAM block on cloud providers: Ollama can be installed and running while every request goes to Google, and "nothing loaded" is then a true sentence about a program the app is not using — which reads as the panel being out of sync.
A fact shown in the wrong section is not merely misplaced, it is inert: it can only report a decision already made, never inform one.
Data ended with a panel called "What leaves this machine", and every part of it belonged elsewhere:
| It showed | Which is really |
|---|---|
| calls today / all time, prompt + completion tokens, a by-model table | Logs, restated — over a chosen window, and better read |
| "Where data goes" — the hosts contacted | Sources, which also says which queries reach them and whether they still find anything |
| "Fully local" / "Cloud inference active" | Model — it is the consequence of that switch |
The verdict now sits as the hint under Where the model runs, so it is part of making the choice rather than a report filed three sections away from it. Data is left doing one job: where the files are, what can be cleared, how long each kind is kept.
Test for a new row: name the question it answers. If that is not the question in the section's title, it is in the wrong section — and if it is the consequence of a control, put it on that control.
Sources sorted Subjects, categories and topics by most enabled first, then by
idea count. So switching a topic on lifted its category — often its whole
Subject — to the top of the page: the card you had just clicked moved out from
under the pointer, and the next one you wanted was no longer where you left it.
The ideas tiebreak did the same on a slower clock, quietly rearranging the
page as batches landed.
A sort key that the act of selecting can change is not an order, it is an animation. Sort by something the interaction cannot touch — a name — so an item is in the same place on every visit, before and after you touch it. That is what lets someone build muscle memory for a list of thirty-eight.
State belongs in the card, not in the position: the filled checkbox, the accent border and the "N on" count in each heading all say where things stand without moving anything.
Three failures in one session, all of which reported success:
- A CHECK constraint plus
INSERT OR IGNORE. Migration 026 added 6 of 11 rows and said OK.OR IGNOREswallows a CHECK violation exactly as quietly as a duplicate, so the Jira sources simply did not exist. - A missing path segment. The Jira adapter built
host/rest/...where the instance serveshost/jira/rest/.... Every query 404'd. - A unit that does not exist. JQL has
h,d,w, andmfor minutes — there is no months unit.created >= -18Mis not rejected; it parses as something matching nothing and returns HTTP 200 withtotal: 0. The same query with-540dreturned 238 of 6,516.
The third is the one to remember: a dead source and an empty result set look
identical, and zero_streak needs three fetch runs to tell them apart. So
Replay the adapter, not the endpoint. Probe the exact query you are about to seed, through the same code path, and check the count is non-zero — not merely that the request succeeded.
A moved repository behaves the same way: man-group/arctic and
quantlib/quantlib both returned 0 issues because both were renamed.
A run reported jira — "timestamp precision": fetch failed. Diagnosis by
curl: apache.org answered in 0.29s, issues.apache.org timed out on both
IPv4 and IPv6, a different Jira answered in 0.68s. Conclusion drawn and stated
confidently: the host is unreachable, the adapter is fine. A migration was
written on the strength of it.
Half of that was wrong. The database said so:
issues.apache.org/jira "data corruption" 29 documents
"memory leak" 25 documents
"timestamp precision" 30 documents, then failing
The app had been reaching that host all along. curl cannot reach it from
this shell — proxy, user agent, something environmental — and I generalised
from an instrument that is not the one the app uses. The real fault was one
slow query, not one dead host: every failure landed at 33–35 seconds, which is
two attempts against a 20-second ceiling, because text ~ "..." scans an
entire Jira and Apache's holds millions of issues. Raising that adapter's
timeout to 45s fixed it — items_seen: 30, first try.
Diagnose with the code that fails, not with a different HTTP client. A source that times out is indistinguishable from a source that is down, and the tool you reach for to tell them apart has its own network stack, its own proxy settings and its own user agent.
curldisagreeing with the app is evidence aboutcurl.
The migration was still right, for a reason that survived the wrong diagnosis: one hostname carrying an entire adapter is a single point of failure whatever the failure turns out to be. A correct decision reached through a wrong reading is not a vindication of the reading.
Thirty queries had never matched a single thread. The documented cause was "too many words", and migration 022 had already repaired ten on that basis. Both were wrong, and the proof is one line:
"git rewrite history filter-branch problem" freshness 18 0 documents
"git rewrite history filter-branch problem" freshness off 8 documents
(dated 2010, 2014, 2016)
The adapter sends fromdate — an 18-month window — and accepted=False.
Neither was in the probe that produced the rule. The query was never too long;
Stack Overflow's git corpus is a decade old and the freshness window excludes
all of it. 022's own replacement, inotify watch limit, is recorded there as
"probed live, 0 -> 5" and has since gone two complete fetch runs without
matching anything — because that probe had no freshness window either.
A probe must apply every filter the real call applies. A partial probe does not fail, it succeeds differently — and it produces a documented rule that is confidently wrong about its own cause. A fix derived from the wrong cause looks exactly like a fix.
The rule had now been broken three times by the people who wrote it, which is
evidence about the rule's cost rather than about the people. So engine probe
exists: it reads the same ADAPTERS map runFetch does, takes freshness from
the same settings, accepts a JSON file of candidates, writes nothing, and exits
non-zero if anything came back empty. Following the rule is now one command
instead of a hand-rolled script, which is the only version of a rule that
survives being inconvenient.
And shortening has a second edge nobody had measured. It revives a query and can gut it, and the second failure is the one that hides, because it produces documents rather than silence:
"windows path length" 5 documents Tesseract, Spotify SDK, PAdES signatures
"git large repository" 5 documents "Oh My Zsh adds bloat", "Before GitHub"
Alive, and worthless — they would have turned a zero-document query into a
zero-complaint one, which reads as coverage on every screen in the app. The
target is not short, it is short and still about one thing. An error code or
API name is both at once, which is why several of 033's replacements are
identifiers rather than descriptions — though not blindly: MAX_PATH was probed
and rejected, five documents, none about paths.
The bug: the installer sitting in release/ was built before nine
migrations, four source adapters, prompt v9 and the entire Runner page. Nothing
about it looked wrong. It ran, it opened, it had the right name and the right
version number in its filename — and it was months of work out of date. The
Desktop folder built from it was worse, because a folder addressed to somebody
else carries an implicit claim that it is current.
The general shape: the code is the only thing that changes when you change it. The docs, the build output, the shareable folder and the git history all have to be moved by hand, and each of them fails silently by simply continuing to say the old thing, confidently.
Finishing is not "the code works". It is: committed, documented, rebuilt, and re-released. A session that stops before that has produced a repository that disagrees with itself, and no message will be printed about it.
scripts/release.mjs exists to collapse three of those four into one command,
and refuses to run on a dirty tree so the fourth cannot be skipped either. It
compares the installer's mtime against the commit and warns when the binary is
older — the one part it cannot rebuild itself. See "Ending a session" in
HANDOFF.md.
The same rule is why this file exists at all: a fix that is not written down is a fix that gets undone.
The bug: four in a row, all from the same page, all found by looking at it.
dir="rtl"went on the page container. Flex honours direction, so the navigation rail jumped to the right edge and the page stopped matching every other page in the app.dirbelongs on the prose, not on the furniture.- The looser Hebrew leading went on the whole page, so every rail row grew and the list shifted down. Switching language appeared to move the furniture. Reading styles apply to the reading column.
- A term was transliterated. "dedup + judge" shipped as
דדופ ושיפוט— the English in Hebrew letters, useless to anyone who did not already know it. - One label in a set was translated and the others were not —
Youamong five English lifelines, which reads as a string that leaked.
The general shape: switching language changes the size, direction and grouping of things, and only the last of those is about words. A translation that is correct word by word can still be wrong on the page.
Three questions before shipping a second language. Does anything MOVE that should not — rails, columns, row heights? Does every mixed-script run declare its direction, or does the punctuation drift? And is each term either kept in the original because the user will see it on screen, or explained in ordinary words — never spelled out phonetically?
The rule the app follows: the explanation translates, the interface does not. Every noun the reader will hunt for on screen stays in Latin script inside the Hebrew sentence. That is what makes the manual usable rather than a parallel vocabulary — and it is why the Hebrew here is bidirectional by design, which is what the first three bullets are all really about.
The bug: the prompt shown when you close the window with unpushed work is drawn in the app, and the main process falls back to a native message box if the renderer cannot draw it. That fallback is right — a prompt a hung window can swallow makes the app unclosable. What was wrong was what the clock measured: 1500 ms for the user's answer. Read the dialog for two seconds and a Windows message box appeared stacked on top of the app's own one, both live, both clickable. The user found it on the first look.
The timeout was written to ask "is anything alive in there to draw this" and was implemented to ask "has the person decided yet". Those are the same line of code and completely different questions.
The fix is the shape to reuse: the renderer acknowledges on receipt, and the clock
covers only the acknowledgement. Once it lands the deadline is gone and the dialog
can sit open all afternoon. The ack proves the renderer was alive a moment ago, not
that it still is, so webContents 'unresponsive' remains as a backstop for one that
acknowledges and then wedges.
Any timer whose expiry causes a second thing to appear: write down what it is measuring. If the answer contains the word user, it is wrong. Machines get deadlines; people get as long as they need.
A related smell in the same family: a "did it work" timeout on an operation whose duration is a person's attention span — a confirm, a file picker, a paste. The thing to time is the mechanism, never the human in front of it.
- What else does this change? Include cascades — grep
ON DELETEinmigrations/. - Does anything on screen still show the old value?
- Can the row still be where it is, under the active filter?
- If the change is invisible, does it say what happened?
- Does the control's label match what it did?
- Can it be undone from where it was done?