--- name: flotilla-architecture description: "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`, not SvelteKit's built-in `$lib`, which also resolves. 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/.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/lib` stays reusable by other apps. - `src/app` modules can be imported from anywhere (routes, components, other modules, the boot sequence) without pulling in UI. - `core.ts` can't import the policy modules that depend on it, so they push themselves onto `appPolicies` when imported, and `core.ts` builds the `App` lazily after they have registered. `flotilla-state` covers 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.svelte` imports `navigate` from `@app/modal`. Don't copy it. A lib component that needs app behavior takes it as a prop, or moves to `src/app/components`, as `IconInput` and its picker buttons did. - **app → components.** `routes.ts` (`goToChat` opens `ChatEnable`, `goToEvent` opens `NoteDetail`), `share.ts` (`Share`, `ShareEvent`), `deepLinks.ts` (`Search`) and `speech.ts` (`OpenRouterEnable`) import a component so they can open a modal mid-flow. Push adapters and deep links reach those flows from outside any component, and the rest have several component callers, so each stays in one module. `editor/` holds `.svelte` files of its own (suggestion popovers), which `makeEditor` mounts. - Nothing under `src/app` or `src/lib` imports from `src/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/`. It also holds the CSS families for app concepts (`chat.css`, `room.css`, `role-badge.css`, `space-menu.css`), which `src/app/app.css` imports. `flotilla-views` covers component conventions. **Core and session** - `core.ts`: the `App` store, plugin stores, `login`, and the `reader`/`writer`/`command` shortcuts - `session.ts`: restores the saved session at boot; `logout` - `policies.ts`: the ingest, auth and socket policies installed on every app - `storage.ts`: `kv`/`ss` (Capacitor Preferences and SecureStorage) and the per-user IndexedDB cache - `sync.ts`: `syncApplicationData`, the background sync of user data, spaces and DMs - `settings.ts`: the `Settings` plugin over encrypted app data, plus notification settings - `repository.ts`: the `LatestEvents` plugin, each watched author's most recent event - `thunks.ts` (publish status by event id), `signer.ts` (signer request tracking and `signerHealth`) - `env.ts`: every `VITE_` value, parsed - `logger.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 tracking - `modal.ts`, `modal.svelte.ts`: `pushModal`, `popModal`, `navigate`, and the modal stack - `toast.ts`, `title.ts`, `theme.ts`, `icons.ts` (icon picker options), `drafts.ts` - `editor/`: flotilla's `@welshman/editor` setup (`makeEditor`), with its suggestion popovers and node views **Spaces, rooms and administration** - `relays.ts`: relay URL encoding for routes, socket status, the LiveKit endpoint and detection - `rooms.ts`: helpers over `rooms.get()`, and the user's rooms and spaces - `access.ts`: joining, invites, relay auth errors - `management.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`), comment/delete filters, and `partitionByActivity`, which the list pages use to sort a kind by latest comment - `feeds.ts`: `makeFeed`, `makeFeedContext`, `makeScrollLoader`, `makeCalendarFeed` - `classifieds.ts`, `articles.ts`, `pins.ts` (a person's pinned notes), `pinboards.ts` - `reactions.ts`, `social.ts` (display names, comment trees, muting), `render.ts` (events as text), `statuses.ts` (NIP-38), `uploads.ts` (Blossom, image compression) - `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, and the `AbortError`/`TimeoutError` a join rejects with) **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`, `deepLinks.ts`, `keyboard.ts` - `dictation.ts` (speech-to-text) and `speech.ts` (read aloud), both through OpenRouter A feature gets a `src/app/.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`, `buildUrl`, `normalizeTopic`) - `html.ts`: DOM helpers such as `isMobile`, `createScroller`, `copyToClipboard` - `indexeddb.ts`: the `IDB` wrapper that `storage.ts` builds on - `feeds.ts`: saved feed definitions (kind `FEED`) over `@welshman/feeds`. It is unrelated to `@app/feeds`, which loads events. - `currency.ts`, `transition.ts`, `implicit.ts` (hands state from one page to the next) - `test/`: the DEV-only hooks the e2e harness injects through - `components/`: the design system, entered through `theme.css` (see `flotilla-views`), which `src/app/app.css` imports ahead of the app's own CSS families ## 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()`. `applySavedTheme()` stamps the last theme and font size from `localStorage` before first paint. Then, in order: 1. `restoreSession()` restores the saved session, if there is one, which builds a user-scoped `App` through `login`. 2. The device, wallet and notification stores sync to `kv`/`ss`. 3. It waits for storage, then `setupDeepLinks()` listens for warm-start links and handles a cold-start one. 4. Each long-running subscription goes onto one `unsubscribers` list: `setupHistory`, `syncApplicationData`, `setupShareIntents`, `syncKeyboard`, badges, `Push.sync()`, `syncSignerAlerts`, and `syncTheme`, which mirrors the theme stores back to `localStorage`. 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.** `SvelteKitPWA` in `vite.config.ts` generates the service worker and manifest, except when `FLOTILLA_DESKTOP=1`. `src/service-worker.js` only claims clients. - **Android/iOS.** Capacitor wraps `build/` (`capacitor.config.ts`). `scripts/build/app.sh` runs the web build, `cap sync`, and native asset generation. - **Desktop.** `electron/main.ts` starts the Capawesome Electron platform, driven by `scripts/desktop/build.sh` and `scripts/desktop/dev.mjs`. - **`server.js`.** A Hono server that serves `build/`. For `/join` and `/spaces/...` URLs it rewrites the OpenGraph tags from the relay's NIP-11 document, fetched through welshman's `Relays`. `vite.config.server.ts` bundles it and the `Dockerfile` runs it. It is not an API, and the app works from any static host. Platform checks call Capacitor directly. There is no wrapper module: ```ts 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/`: `AndroidPushFallbackPlugin` and its worker (push without FCM), and `ShareIntentPlugin`. `MainActivity.java` registers them, and JS binds them with `registerPlugin` (`push/adapters/android.ts`, `share.ts`). - `ios/App/ShareExtension/`: the extension can't call into the app, so it opens a `flotilla://share` URL. `handleDeepLink` in `src/app/deepLinks.ts` passes that to `shareFromNative`. `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. - `.env` is 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.sh` sources `.env` without overwriting variables already set, then fills the `{NAME}`, `{URL}`, `{ACCENT}` and `{DESCRIPTION}` placeholders from `src/app.html` in `build/index.html`. `server.js` reads `VITE_PLATFORM_NAME` and `VITE_PLATFORM_DESCRIPTION` at runtime. - A non-empty `VITE_PLATFORM_RELAYS` turns on platform mode, which disables space browsing and makes the first platform relay the home page (`goToHome` in `routes.ts`, `PrimaryNav`, `sync.ts`). - `VITE_THEME`, exported as `FL_THEME`, selects the design preset in `src/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 lint` runs prettier and eslint over `src`, `e2e` and the configs, `pnpm run check` runs svelte-check, and `pnpm run format` formats changed files. - `.husky/pre-commit` runs lint and check, and refuses to commit while a `link:` override is in place. CI (`.gitea/workflows/ci.yml`) runs lint, check and the Electron TypeScript build, plus a full build on pushes to `dev`. - Flotilla has no unit tests. `e2e/` is a Playwright suite against a real zooid relay in Docker; `e2e/ARCHITECTURE.md` explains the harness and `e2e/USER_STORIES.md` lists the stories the specs cite. Agents don't run it. Its only footprint in the app is `src/lib/test/`. - `scripts/link-deps.mjs` links `../welshman/packages/*` by writing temporary `link:` overrides into `pnpm-workspace.yaml`, installing, and restoring the file. Without those overrides welshman comes from the registry, so read its source under `node_modules/@welshman/*/dist`, or in `../welshman` when 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 through `reader` and `writer` from `@app/core`. See `flotilla-model` ("Adding a kind") and `welshman-domain`. - **A space section for a kind** → a route under `src/routes/spaces/[relay]/`, with the kind in `CONTENT_KINDS`. The walkthrough below names every file involved, and `flotilla-model` ("Adding a kind") and `flotilla-views` ("Adding a space content page") have the checklists. Add the section to the regexes in `server.js`, or its link previews are titled as a room. - **Non-UI logic for one feature** (scoring, filtering, stores keyed by URL) → `src/app/.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 a `DerivedPlugin` in `src/app`, exposed with `usePlugin`. See `flotilla-state`. - **A preference** → a `SettingsValues` field in `settings.ts` if it follows the user, or a `kv`/`ss` store if it belongs to the device. See `flotilla-state`. - **A relay or network policy** (what to ingest, when to AUTH, which sockets may open) → an `AppPolicy` in `policies.ts`. See `flotilla-state` and `welshman-net`. - **Data every joined space needs locally** → the filters in `syncSpace` in `sync.ts`. Data that one page needs is loaded by that page. See `flotilla-state` and `flotilla-views`. - **A modal or dialog** → `src/app/components/.svelte`, opened with `pushModal`. See `flotilla-views`. - **A generic UI primitive** → `src/lib/components/.svelte` with a CSS family file next to it (`Button.svelte` and `button.css`), and no `@app` imports. See `flotilla-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/.ts`, with inline `Capacitor` checks and a web fallback. If no Capacitor plugin fits, write one under `android/app/src/main/java/social/flotilla/`, register it in `MainActivity.java`, and bind it with `registerPlugin`. An iOS extension reaches the app through a `flotilla://` deep link. - **A deployment setting** → a `VITE_` variable (see Env and branding). - **Startup wiring** → a `setup*` or `sync*` function in the owning module that returns an `Unsubscriber`, 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 (`deriveTopicCounts`, `getStatus`, `matchesTopic`, `matchesQuery`). Each is a small function over a domain reader: ```ts 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. Its `ContentActions` row opens `ClassifiedEdit` through `editForm`. `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 `$events.one(address, [url]).$`. Articles are composed on a full page (`spaces/[relay]/articles/create`, built by `makeArticleCreatePath`) instead of in a modal. The page reads `?h=` and `?shareToChat` and renders `ArticleForm`. ## Related skills - `flotilla-state`: the `App` instance and plugins, policies, persistence, sync, publishing - `flotilla-views`: routes and layouts, components, modals, loading data from components - `flotilla-model`: spaces as relays, NIP-29 rooms, NIP-86 management, content kinds, routing - `welshman`: overview of the packages - `welshman-app`: `App`, `use()`, `AppPolicy`, `DerivedPlugin`, commands and thunks - `welshman-domain`: readers, writers, and adding a kind - `welshman-net`: the pool, sockets and socket policies - `welshman-util`: kind constants, tag specs, `RelaySelection`