Skip to content

Latest commit

 

History

History
255 lines (200 loc) · 13.8 KB

CONTRIBUTING.md

File metadata and controls

255 lines (200 loc) · 13.8 KB

Contributing to Lumen Browser

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.

Before you open a PR

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` once

npm 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.

Where does my file go?

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

Rule 0: user-visible text goes through t()

{{ 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.json

Adding 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.

The wording rules check:wording enforces

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.


Rule 1: every type / interface lives in src/types/

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. GatewayView exists in both drivePage.ts and searchPage.ts, and Block in both explorerPage.ts and networkPage.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.

Rule 2: all CSS lives in src/css/

  • No <style> blocks in .vue files, and no literal style="…". 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.css is the spacing/radius/text scale (axis-composable: p-, px-, py-, pt-/pr-/pb-/pl-, same for m-/gap-), colors.css the palette, flex.css/layout.css/sizing.css the box, theme.css the 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 -aNN suffix (bg-primary-a10 is 10%). Never the single-digit form.
  • @keyframes live in src/css/animation.css.
  • No @media today - fixed-size desktop app. A real breakpoint goes in a new src/css/responsive.css, imported after layout.css.
  • Vendored CSS (public/lib/bibi/) is exempt from all of it. Foreign code.

Rule 3: a component is never handed a function

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.

Rule 4: say one thing one way

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 }).

Rule 5: consistency beats design

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.


The convention checker

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.

Writing tests

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.


Release checklist

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.