This project is kept consistent by rules a machine checks, not by review vigilance. Everything below is enforced automatically and a PR that violates it will be rejected.
Every rule here exists because something went wrong once. If you want the story behind one, it is in ARCHITECTURE.md - read that before your first PR, and whenever a rule looks arbitrary.
npm test # conventions, IPC contract, test reachability, locale catalogues + untranslated strings, eslint (src + electron), vue-tsc, unit tests
npm run test:e2e # Playwright, needs `npx playwright install chromium` oncenpm test is also wired as prebuild, so npm run build (and pack / dist / dist:win)
refuses to produce a build if anything fails. There is no way to skip it locally and still ship a
working build.
The folder a file lives in decides what it may import. The full map, the dependency direction and the questions to ask are in ARCHITECTURE.md. The short version:
| I am writing… | It goes in |
|---|---|
| A primitive that knows nothing about Lumen | src/ui/ |
| One domain value, drawn (hash, address, status) | src/entities/ — check it does not already exist |
| A modal ending in Cancel + one action | src/dialogs/, built on UiDialog |
| Any other modal | src/dialogs/, built on UiModal |
| A chunk of one page, extracted | src/panels/, named …Panel.vue |
| Logic with no view state | src/internal/services/ |
| Reactive state shared by two components | src/composables/ |
| A type or interface | src/types/ — always |
| A stylesheet | src/css/ — always |
{{ t('Save') }}, :title="t('Back')". The key is the English text — do not invent an id, and
do not build a key by interpolation (t(\Delete ${n}`)cannot be looked up; writet('Delete {n}', { n })). Placeholder examples that are not English (lmn1…`, a URL) stay plain.
For a string in a module-level table - a const array of labels, a code→message map - t() at
the definition would freeze whatever language was loaded first. Use markForTranslation() there so
the extractor sees it, and t() where the value is drawn.
npm run i18n:extract # refresh src/locales/*.json from the source
npm run check:i18n # same, read-only
npm run check:i18n-strings # fails on user-visible English not going through t()
npm run check:wording # fails on two strings saying the same thing
# Filling a catalogue, a batch at a time. Only adds the keys the batch names,
# refuses a key the source does not ask for, and refuses a translation that
# has lost or invented a {placeholder}.
node scripts/merge-locale.mjs fr batch.jsonAdding a language: a new src/locales/<code>.json, its code, endonym and
flag in LOCALES (src/internal/services/i18n.ts), TARGET_LOCALES in
scripts/extract-strings.mjs, and the import in src/stores/i18nStore.ts. The
picker in lumen://settings and the onboarding step both read LOCALES, so
neither needs editing. Set rtl: true if the script runs right to left.
Each was written after finding the drift it now blocks. The counts came out near 50/50 every time, so these are choices, not a majority followed.
…, never ... |
|
| A detail-row label carries no colon | Time, not Time: — the layout supplies punctuation. A lead-in that introduces prose keeps it (Note:). |
| A sentence ends with a full stop; a label or noun phrase does not | Failed to load gateways. but Backup failed, Password required |
One verb for failure: Failed to … |
not Unable to, not Could not |
not available, never unavailable |
|
| Sentence case | Title Case only for the name of a page, as the route table spells it |
A number that varies goes in a {placeholder} |
one {count} per page, not one key per size |
Delete destroys, Remove takes out of a list |
delete a contact, remove a row |
A number the code enforces goes in as {placeholder} |
Password must be at least {min} characters. reads MIN_PASSWORD_LENGTH |
| Nothing shouts | the explorer's header row is uppercased by CSS; an ALL-CAPS source loses accents to text-transform |
| A term kept in English is spelled one way inside a translation | "l'instantané du drive" beside "Enregistrer dans Drive" reads as two different things |
Both checks are wired into npm test. All ~2 000 strings go through t(); the handful that
deliberately do not are named in scripts/check-untranslated.mjs, each with the reason.
All twelve catalogues beside English are complete. An empty entry renders the English, so a
language added later can be filled a screen at a time without ever showing a hole.
The end-to-end suite pins the language. A profile that has never chosen one follows the OS, so
on a French machine every assertion about an English label fails. tests/e2e/electron/support/launch.ts
passes --lang=en-US (Chromium's own locale, which navigator.languages reports) and
LUMEN_SYSTEM_LANGUAGES=en (what the main process reports the OS is set to). The renderer consults
both, so both are needed.
The trap markForTranslation() exists for. A const table at the top of a module - the route
names, the fatal-error map, a DEX listing - is built when the module is imported, which happens
long before a profile's locale is read. t() there returns English and keeps returning it for the
whole session, however many times the value is drawn. Nothing fails; the page is simply in the
wrong language, and only in the places that used a table. If a string is written down anywhere
other than where it is displayed, it wants markForTranslation() at the definition and t() at
the draw site.
Enforced by ESLint. No inline declaration anywhere in src/**/*.ts or src/**/*.vue outside
src/types/.
- One file per concept. A type specific to one page is named after it (
src/types/drivePage.ts). A genuinely shared shape gets its own descriptive file (src/types/tab.ts). - Import with
import type { Foo } from '../types/foo'. Never re-declare a shape that exists. - Check whether a file already covers the concept before creating one. A new settings field belongs
in the existing
src/types/settings.ts. - Same name ≠ same concept.
GatewayViewexists in bothdrivePage.tsandsearchPage.ts, andBlockin bothexplorerPage.tsandnetworkPage.ts. Merging either pair would force a rename and imply a kinship that isn't there. The union of two unions is the danger: it is the superset of both, so a value that should not compile for one of them silently does.
Not covered: anonymous unions in props. An inline union in defineProps<{ … }>() stays where it
is - it declares no named type, so there is nothing to drift. Give it a name in src/types/ as
soon as it is referenced anywhere else: when it backs a Record map, when another file imports it,
or when it drives state rather than styling.
- No
<style>blocks in.vuefiles, and no literalstyle="…". A computed:style="…"for a genuinely per-instance value (an avatar hue) is the only exception. - Reuse before adding. Fourteen files, each holding one axis:
scale.cssis the spacing/radius/text scale (axis-composable:p-,px-,py-,pt-/pr-/pb-/pl-, same form-/gap-),colors.cssthe palette,flex.css/layout.css/sizing.cssthe box,theme.cssthe custom properties every other file reads. If nothing covers it, follow the same convention and snap values to the existing scale rather than inventing a one-off pixel value. - Opacity uses the two-digit
-aNNsuffix (bg-primary-a10is 10%). Never the single-digit form. @keyframeslive insrc/css/animation.css.- No
@mediatoday - fixed-size desktop app. A real breakpoint goes in a newsrc/css/responsive.css, imported afterlayout.css. - Vendored CSS (
public/lib/bibi/) is exempt from all of it. Foreign code.
Enforced as no-function-prop. A component that receives a function receives its own presentation,
so it cannot be read on its own. Two alternatives:
- A module both sides import — usually
src/internal/services/. - An
emit— when the parent is being asked to do something.
Genuine exceptions are listed by name in an allowlist inside the rule, each with its reason.
Not mechanically checkable, and the one most of the others protect.
- A domain value has one rendering →
src/entities/. - A message the user reads has one wording.
copyToClipboardWithToast(text)takes no label on purpose. - A format written and read has one definition, shared by both directions.
- Navigation goes through
useTabNavigation().open(url, { blank, push }).
When two near-identical values diverge for no reason - a gap-6px beside a gap-8px, two alphas
one hundredth apart - snap them to one canonical value. Do not preserve a difference nobody
chose. The design adapts afterwards.
npm run check:conventions — seventeen rules, all blocking except #9. Each explains its reasoning at
the top of its own check in scripts/check-conventions.mjs. Read it there rather than guessing.
| # | Rule |
|---|---|
| 1 | No literal style="" |
| 2 | No <style> in a .vue |
| 3 | No duplicate CSS selector |
| 4 | No dead CSS class |
| 5 | No HTML class with neither a CSS rule nor a JS selector |
| 6 | No hand-drawn <svg> — check lucide-vue-next first |
| 7 | No empty class="" |
| 8 | No two classes on one element fighting over the same property |
| 9 | (warning) Repeated markup that might want a src/ui/ component |
| 10 | No unused import |
| 11 | No unused top-level declaration |
| 12 | No blank line inside a tag's attribute list |
| 13 | No literal v-if="true" / v-if="false" |
| 14 | No class repeated twice in one attribute |
| 15 | No dead CSS custom property |
| 16 | No function-typed prop |
| 17 | No component drawn in a template but never imported |
Three more checks run beside it: check:i18n (locale catalogues in step with the source),
check:i18n-strings (no user-visible English outside t()) and check:wording (no two strings
saying the same thing).
Known blind spots. Rules 4 and 5 compare tokens where a class can actually be applied (*class
attributes, string literals). They do not see a class passed inside an object (:handlers="{ … }")
or through a slot.
Worth a test: a rule with an order or a threshold, a format that is both written and read, anything that could be wrong without failing loudly.
Not worth one: a pass-through, a wrapper carrying a constant, markup.
End-to-end: three projects, and playwright.config.ts says which is which — renderer (the Vue
app with a mock bridge), electron (the real app, one spec at a time), and chain (real
transactions, opt-in behind LUMEN_E2E_CHAIN=1). A renderer spec stubs a call by passing its path
to openApp — overrides are source strings, because they are serialised into the page:
await openApp({
'wallet.getBalance': `() => Promise.resolve({ ok: true, balance: { amount: '5000000' } })`,
});What the tests can and cannot reach, and what still has no coverage, is in ARCHITECTURE.md. Read it before assuming a green run means much.
Most of what this list used to ask for by hand now has a test. Run those first — they are cheaper and they do not get bored:
npm run test:e2e # renderer (mocked) + electron (the real app)
LUMEN_E2E_CHAIN=1 npm run test:e2e:chain # real transactions, real money| Covered by | What it proves |
|---|---|
chain/send.spec.ts |
A real send on the real chain, both directions, balances asserted — including that an unlinked account links its own Dilithium key by signing. |
electron/profile-security.spec.ts |
No profile on first run, a wallet minted on creation, a password refused for being too short, lock and unlock, and that the password survives a restart while the session does not. |
electron/profile-backup.spec.ts |
A profile exports, restores, and refuses a file that is not one; a wallet is recoverable from the backup after the profile is gone. |
electron/site-bridge.spec.ts |
A site gets the site API and not the app one, a permission prompt appears, Deny is honoured, and "Always allow" is remembered. |
electron/upload-folder.spec.ts |
A directory upload, its progress, its cancellation, and that a duplicate upload id is refused rather than racing. |
electron/ipfs.spec.ts, electron/boot.spec.ts |
Add/pin/unpin/IPNS round trips; the app boots with its preload attached and its handlers registered. |
What is still hands-on against a real build (npm run pack), because nothing covers it:
- Approve a signature from a site — the Keplr/Leap shim must show the approval modal, and rejecting must actually stop the signature. The site-bridge test covers the permission prompt in front of a pin, not the signing modal.
- Pause and resume a pin job, not just cancel it.
- Restore a Drive backup exported by a previous version — the automated one round-trips a backup written by the same build, which cannot notice a format that changed.
- Install an extension, and confirm an injected wallet still works on an IPFS site.
- Video fullscreen on an ordinary https page, which no test drives and which a permission handler silently broke once.