19 KiB
| name | description |
|---|---|
| flotilla-architecture | Use this skill when deciding where new code belongs in flotilla: which layer (routes, app/components, app, lib) or welshman package owns it, which src/app module to extend, or how a kind-based space feature is laid out across the layers. Also use it for the layer and import rules, path aliases, the boot sequence, platform-specific behavior (Capacitor, Android, iOS, Electron, PWA), the link-preview server, env and branding variables, the e2e harness, and the lint/check tooling. |
Flotilla architecture
Flotilla is a client-only SvelteKit app, built with adapter-static and an index.html fallback,
with ssr = false in src/routes/+layout.ts. The same build ships as a web app/PWA, inside
Capacitor for Android and iOS, and inside Electron for desktop. Welshman does almost all of the
nostr work. Flotilla's own code is app policy (what to sync, when to authenticate, what to show)
and UI.
The layers
| Layer | Path | Import as | May import |
|---|---|---|---|
| Routes | src/routes |
— | anything |
| App components | src/app/components |
@app/components/X.svelte |
@app, @lib |
| App modules | src/app/*.ts, editor/, push/ |
@app/x |
@lib, each other |
| Lib | src/lib |
@lib/x |
external packages only |
svelte.config.js defines the aliases @src, @app, @lib and @assets. Use @lib.
SvelteKit's built-in $lib also resolves, but only four stray imports use it (in relays.ts,
callEngine.ts and VoiceRoomJoinDialog.svelte). There is no barrel file, so import each
component by its path (@lib/components/Button.svelte), not from $lib/components as the
AGENTS.md example has it. Icons come from @assets/icons/<name>.svg?dataurl.
SvelteKit's $app/* ($app/navigation, $app/state, $app/stores) is an external dependency,
unrelated to flotilla's @app/*. App modules use it freely, for example modal.ts, routes.ts
and sync.ts.
Why the graph is one-way
src/libstays reusable by other apps.src/appmodules can be imported from anywhere (routes, components, other modules, the boot sequence) without pulling in UI.core.tscan't import the policy modules that depend on it, so they push themselves ontoappPolicieswhen imported, andcore.tsbuilds theApplazily after they have registered.flotilla-statecovers this under "App policies".
Exceptions
No lint rule enforces the layers (eslint.config.js has no import restrictions), so review is the
only gate.
- lib → app.
Link.svelteimportsnavigatefrom@app/modal, andImageInputButton.svelteandIconPickerButton.svelteopen app modals. Don't copy them. A lib component that needs app behavior takes it as a prop, or moves tosrc/app/components. - app → components.
routes.ts(goToChatopensChatEnable),share.ts(Share,ShareEvent) andspeech.ts(OpenRouterEnable) import a component so they can open a modal mid-flow.editor/holds.sveltefiles of its own (suggestion popovers), whichmakeEditormounts. - Nothing under
src/apporsrc/libimports fromsrc/routes.
Top-level layout
| Path | What it is |
|---|---|
src/routes |
SvelteKit pages; the root +layout.svelte also runs the boot sequence |
src/app |
Flotilla's state, policies and feature logic, plus components/ |
src/lib |
App-agnostic utilities and the design-system components |
src/assets/icons |
SVG icons |
static/ |
Logo, PWA icons, fonts, sounds |
android/, ios/ |
Capacitor native projects, with flotilla's own plugins and iOS share extension |
electron/ |
Desktop shell on @capawesome/capacitor-electron; a separate npm project |
server.js |
Optional node server: serves build/ and adds link-preview metadata |
e2e/ |
Playwright suite against a real relay; start with e2e/ARCHITECTURE.md |
scripts/ |
Build, desktop, version-bump and welshman-linking scripts |
docs/feature_matrix.html |
Standalone feature matrix page |
src/app by concern
src/app/components is flat except for hosting/. flotilla-views covers component conventions.
Core and session
core.ts: theAppstore, plugin stores,login, and thereader/writer/commandshortcutssession.ts: restores the saved session at boot;logoutpolicies.ts: the ingest, auth and socket policies installed on every appstorage.ts:kv/ss(Capacitor Preferences and SecureStorage) and the per-user IndexedDB cachesync.ts:syncApplicationData, the background sync of user data, spaces and DMssettings.ts: theSettingsplugin over encrypted app data, plus notification settingsrepository.ts:derive*helpers over the current app's repositorythunks.ts(publish status by event id),signer.ts(signer request tracking)env.ts: everyVITE_value, parsedlogger.ts(log capture and sending),analytics.ts(Plausible pageviews),device.ts(a device id)
Navigation and UI plumbing
routes.ts: path builders (makeSpacePath,makeContentPath, …),goTo*, history trackingmodal.ts,modal.svelte.ts:pushModal,popModal,navigate, and the modal stacktoast.ts,title.ts,theme.ts,icons.ts(icon picker options),drafts.tseditor/: flotilla's@welshman/editorsetup (makeEditor), with its suggestion popovers and node views
Spaces, rooms and administration
relays.ts: relay URL encoding for routes, socket status, LiveKit detectionrooms.ts: helpers overrooms.get(), and the user's rooms and spacesaccess.ts: joining, invites, relay auth errorsmanagement.ts(NIP-86 admin checks, bans),roles.ts(member roles)actionItems.ts: the admin review queue (reports and pending joins)featured.ts(the space owner's featured content),roomPins.ts,commands.ts(NIP-CD slash commands)hosting.ts: client for the hosting backend's HTTP API
Content
content.ts: kind lists (CONTENT_KINDS,REACTION_KINDS,DM_KINDS) and comment/delete filtersfeeds.ts:makeFeed,makeFeedContext,makeScrollLoader,makeCalendarFeedclassifieds.ts,articles.ts,pins.ts(a person's pinned notes),pinboards.tsreactions.ts,social.ts(display names, comment trees, muting),render.ts(events as text),statuses.ts(NIP-38),uploads.ts(Blossom)notifications.ts(unread state, badges),inbox.ts(the home inbox)
Messaging and calls
chats.ts,call.ts(call state),callEngine.ts(LiveKit join, leave, devices)
Identity and payments
nip46.ts,pomade.ts(email login),lightning.ts(wallet, invoices),healthChecks.ts(prompts for missing inbox/outbox relays)
Platform and voice
push/(notification adapters),share.ts,keyboard.tsdictation.ts(speech-to-text) andspeech.ts(read aloud), both through OpenRouter
A feature gets a src/app/<feature>.ts only when it has non-UI logic to hold. Polls, goals,
threads and calendar events have no module; their components use domain readers directly.
src/lib
Lib code is app-agnostic. It may use svelte, SvelteKit, Capacitor and welshman, but never @app,
env, or the App instance. A good test is whether it would work unchanged in another nostr
client.
util.ts: small helpers (errorMessage,AbortError/TimeoutError,buildUrl,normalizeTopic)html.ts: DOM helpers such asisMobile,createScroller,copyToClipboard,compressFileindexeddb.ts: theIDBwrapper thatstorage.tsbuilds onfeeds.ts: saved feed definitions (kindFEED) over@welshman/feeds. It is unrelated to@app/feeds, which loads events.livekit.ts: finds a relay's LiveKit endpointcurrency.ts,transition.ts,implicit.ts(hands state from one page to the next)test/: the DEV-only hooks the e2e harness injects throughcomponents/: the design system, entered throughtheme.css(seeflotilla-views)
Boot sequence
src/routes/+layout.svelte runs the boot sequence. It imports @app/policies for its side effect,
and @app/storage, which registers storagePolicy the same way, so every AppPolicy is on
appPolicies before anything calls app.get(). Then, in order:
restoreSession()restores the saved session, if there is one, which builds a user-scopedAppthroughlogin.- The device, wallet and notification stores sync to
kv/ss. - It waits for storage, then handles a cold-start deep link.
- Each long-running subscription goes onto one
unsubscriberslist:setupHistory,syncApplicationData,setupShareIntents,syncKeyboard, badges,Push.sync().
When login swaps in a new App, the layout runs syncApplicationData again. Routes render inside
AppContainer, behind the login gate, and ModalContainer renders outside it. flotilla-state
covers the gate, login and logout.
Platform layer
One web build runs in several shells:
- Web/PWA.
SvelteKitPWAinvite.config.tsgenerates the service worker and manifest, except whenFLOTILLA_DESKTOP=1.src/service-worker.jsonly claims clients. - Android/iOS. Capacitor wraps
build/(capacitor.config.ts).scripts/build.shruns the web build,cap sync, and native asset generation. - Desktop.
electron/main.tsstarts the Capawesome Electron platform, driven byscripts/build-desktop.shandscripts/dev-desktop.mjs. server.js. A Hono server that servesbuild/. For/joinand/spaces/...URLs it rewrites the OpenGraph tags from the relay's NIP-11 document, fetched through welshman'sRelays.vite.config.server.tsbundles it and theDockerfileruns it. It is not an API, and the app works from any static host.
Platform checks call Capacitor directly. There is no wrapper module:
export const ENABLE_ZAPS = Capacitor.getPlatform() != "ios" // src/app/env.ts
export const HOSTING_ENABLED = Capacitor.getPlatform() !== "ios" // src/app/hosting.ts
if (!Capacitor.isPluginAvailable("Keyboard")) return noop // src/app/keyboard.ts
The iOS flags exist because of App Store payment policy, so anything that takes money checks
ENABLE_ZAPS or HOSTING_ENABLED. isMobile from @lib/html detects a touch screen and says
nothing about the platform.
Flotilla's own native code:
android/app/src/main/java/social/flotilla/:AndroidPushFallbackPluginand its worker (push without FCM), andShareIntentPlugin.MainActivity.javaregisters them, and JS binds them withregisterPlugin(push/adapters/android.ts,share.ts).ios/App/ShareExtension/: the extension can't call into the app, so it opens aflotilla://shareURL.handleDeepLinkin the root layout passes that toshareFromNative.
Push in src/app/push/index.ts chooses an adapter at runtime: the Android fallback, Capacitor
PushNotifications (FCM/APNs through PUSH_SERVER), or web notifications.
Env and branding
src/app/env.ts reads the VITE_ values and exports them as typed constants, with relay lists
parsed by fromCsv and normalizeRelayUrl. In DEV each lookup checks window.__TEST_ENV__ first,
which lets the e2e harness point a browser at its own relays. Elsewhere, only logger.ts and the
about page read a VITE_ value (VITE_BUILD_HASH); other import.meta.env reads are DEV
guards.
.envis committed and holds working defaults..env.local(gitignored) overrides it. There is no.env.template, though AGENTS.md and the README refer to one.- Env is read at build time.
scripts/build-web.shsources.envwithout overwriting variables already set, then fills the{NAME},{URL},{ACCENT}and{DESCRIPTION}placeholders fromsrc/app.htmlinbuild/index.html.server.jsreadsVITE_PLATFORM_NAMEandVITE_PLATFORM_DESCRIPTIONat runtime. - A non-empty
VITE_PLATFORM_RELAYSturns on platform mode, which disables space browsing and makes the first platform relay the home page (goToHomeinroutes.ts,PrimaryNav,sync.ts). VITE_THEME, exported asFL_THEME, selects the design preset insrc/lib/components/theme.css.- The native app name is hard-coded in
capacitor.config.ts(appName: "Flotilla"), outside the env system.
To add a variable, give it a default in .env and export a parsed constant from env.ts. If it
names a relay or host, the e2e harness has to override or mock it (see "Containment" in
e2e/ARCHITECTURE.md).
Tooling and tests
pnpm run lintruns prettier and eslint oversrc,e2eand the configs,pnpm run checkruns svelte-check, andpnpm run formatformats changed files..husky/pre-commitruns lint and check, and refuses to commit while alink:override is in place. CI (.gitea/workflows/ci.yml) runs lint, check and the Electron TypeScript build, plus a full build on pushes todev.- Flotilla has no unit tests.
e2e/is a Playwright suite against a real zooid relay in Docker;e2e/ARCHITECTURE.mdexplains the harness ande2e/USER_STORIES.mdlists the stories the specs cite. Agents don't run it. Its only footprint in the app issrc/lib/test/. scripts/link-deps.mjslinks../welshman/packages/*by writing temporarylink:overrides intopnpm-workspace.yaml, installing, and restoring the file. Without those overrides welshman comes from the registry, so read its source undernode_modules/@welshman/*/dist, or in../welshmanwhen that checkout matches the installed version.
Principles behind placement
Check welshman before writing flotilla code. Several commits replace app code with welshman
primitives: render.ts uses welshman's summarize (9b0d8a55), actionItems.ts uses
rooms.get().pendingJoins (3d66fb31), and rooms.ts uses the membership helpers (847d8984).
When welshman lacks something, add it there. The maintainer also maintains welshman, so a
missing primitive goes upstream rather than into an @app workaround. The Command and
Pinboard kinds live in @welshman/domain, and flotilla's commands.ts and pinboards.ts only
consume them. Expect rejection for app-level retry loops, liveness heuristics, or registries that
duplicate what @welshman/net already tracks.
Add indirection only when it pays for itself. core.ts exports the reader, writer and
command shortcuts because "almost every read or write goes through one of them". Platform checks
stay inline rather than going through a platform module, and drafts.ts is a module-level Map
rather than a persisted store.
Placement guide
- Parsing or building a nostr kind → upstream in
@welshman/domain, used throughreaderandwriterfrom@app/core. Seeflotilla-model("Adding a kind") andwelshman-domain. - A space section for a kind → a route under
src/routes/spaces/[relay]/, with the kind inCONTENT_KINDS. The walkthrough below names every file involved, andflotilla-model("Adding a kind") andflotilla-views("Adding a space content page") have the checklists. Add the section to the regexes inserver.js, or its link previews are titled as a room. - Non-UI logic for one feature (scoring, filtering, stores keyed by URL) →
src/app/<feature>.ts, with no component imports. - A keyed collection of one kind → a plugin. A generic kind's plugin goes upstream in
@welshman/app; a flotilla-specific one is aDerivedPlugininsrc/app, exposed withusePlugin. Seeflotilla-state. - A preference → a
SettingsValuesfield insettings.tsif it follows the user, or akv/ssstore if it belongs to the device. Seeflotilla-state. - A relay or network policy (what to ingest, when to AUTH, which sockets may open) → an
AppPolicyinpolicies.ts. Seeflotilla-stateandwelshman-net. - Data every joined space needs locally → the filters in
syncSpaceinsync.ts. Data that one page needs is loaded by that page. Seeflotilla-stateandflotilla-views. - A modal or dialog →
src/app/components/<Name>.svelte, opened withpushModal. Seeflotilla-views. - A generic UI primitive →
src/lib/components/<Name>.sveltewith a CSS family file next to it (Button.svelteandbutton.css), and no@appimports. Seeflotilla-views. - A non-nostr HTTP service → its own module with typed request functions, a typed error class
and a base URL from env, as in
hosting.ts(hostingFetch,HostingError,HOSTING_BACKEND_URL). - A native capability → JS in
src/app/<capability>.ts, with inlineCapacitorchecks and a web fallback. If no Capacitor plugin fits, write one underandroid/app/src/main/java/social/flotilla/, register it inMainActivity.java, and bind it withregisterPlugin. An iOS extension reaches the app through aflotilla://deep link. - A deployment setting → a
VITE_variable (see Env and branding). - Startup wiring → a
setup*orsync*function in the owning module that returns anUnsubscriber, called from the root layout (setupHistory,syncKeyboard,Push.sync).
Walkthrough: classifieds
Classifieds (NIP-99, kind 30402, CLASSIFIED) touch every layer and follow current conventions
(e6ce3e5e is their redesign). Polls, goals, threads and calendar have the same shape without the
app module.
Domain. Classified in @welshman/domain pairs a ClassifiedReader (title(),
summary(), price(), status(), images(), topics()) with a ClassifiedWriter that has the
matching setters.
Kind registries. CONTENT_KINDS in src/app/content.ts drives sync, notifications, push,
search and the space nav entry. The kind also appears in CONTENT_NOUNS, the kind dispatch in
NoteContent.svelte and NoteContentMinimal.svelte, makeClassifiedPath and makeContentPath in
routes.ts, title.ts, NIP46_PERMS in nip46.ts, and the section regexes in server.js.
NIP46_PERMS leaves out polls, articles and goals, so listing a new kind there is optional.
App module. src/app/classifieds.ts holds the listing logic the page would otherwise inline
(partitionListings, deriveTopicCounts, getStatus, matchesTopic, matchesQuery). Each is a
small function over a domain reader:
export const getStatus = (event: TrustedEvent) => reader(Classified)(event).status() ?? "active"
Components. ClassifiedForm builds and publishes the event, and ClassifiedCreate and
ClassifiedEdit wrap it, supplying only the header. The list page and ComposeMenu open
ClassifiedCreate as a modal. From a room, ComposeMenu sets shareToChat, which also quotes the
new listing into the room. ClassifiedActions opens ClassifiedEdit. ClassifiedItem is the
card, and NoteContentClassified renders a listing wherever NoteContent is used.
Routes. src/routes/spaces/[relay]/classifieds/+page.svelte loads listings and their comments
with makeFeed and filters them with the classifieds.ts helpers. [address]/+page.svelte reads
one listing with deriveEvent(address, [url]).
Articles are composed on a full page (spaces/[relay]/articles/create, built by
makeArticleCreatePath) instead of in a modal.
Related skills
flotilla-state: theAppinstance and plugins, policies, persistence, sync, publishingflotilla-views: routes and layouts, components, modals, loading data from componentsflotilla-model: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routingwelshman: overview of the packageswelshman-app:App,use(),AppPolicy,DerivedPlugin, commands and thunkswelshman-domain: readers, writers, and adding a kindwelshman-net: the pool, sockets and socket policieswelshman-util: kind constants, tag specs,RelaySelection