23 KiB
| name | description |
|---|---|
| flotilla-views | Use this skill when adding or changing a route, layout, page, or component in flotilla: deciding between src/lib/components and src/app/components, naming a component, choosing its props, navigating or opening a modal, drawer, popover or toast, building a create/edit form, loading data from a page or component (detail pages, feeds, infinite scroll), wiring a button to a mutation with loading and error states, or styling a component. |
Flotilla views: routes, components, and how they reach state
Pages read their params, load what they show, and hand identifiers to components. Components are flat, noun-first, and derive the rest from those identifiers.
Routing
No load functions
Nothing renders on a server (ssr = false; see flotilla-architecture for the build), so there are
no +page.ts files. Every page loads its own data in onMount or an $effect, and every layout
is a +layout.svelte.
The tree
/ redirect: goToHome() /home dashboard (Home* sections)
/spaces your spaces + discovery /spaces/create
/spaces/[relay] mobile space menu; desktop redirects to goToSpace()
/spaces/[relay]/[h] room chat /spaces/[relay]/chat space-level chat
/spaces/[relay]/{about,admin,directory,library}
/spaces/[relay]/{threads,goals,polls}[/[id]]
/spaces/[relay]/{classifieds,articles,calendar}[/[address]] articles/create
/chat, /chat/[chat] DMs /people/[npub] profile page
/settings/{profile,alerts,wallet,hosting,content,privacy,theme,about}
/join invite link landing /share share-intent landing
/[bech32] any nip19 entity, resolved and redirected
Params
[relay]:encodeRelay(url)anddecodeRelay(param)insrc/app/relays.ts. Encoding stripswss://and the trailing slash and URI-encodes the rest; decoding normalizes it back. Build paths with the helpers insrc/app/routes.tsrather than by hand.[h]: the NIP-29 room id, unencoded (makeRoomPath(url, h)). Static segments win over it, whichmakeSpaceChatPath(url)relies on: it ismakeRoomPath(url, "chat")and lands on the staticchatpage. A new static segment under[relay]shadows any room with that id.[id]for regular events,[address]for addressable ones.makeSpacePath(url, ...extra)URI-encodes each extra segment and dropsundefined, somakeClassifiedPath(url, address)is safe.[chat]ismakeChatId(pubkeys)fromsrc/app/chats.ts, and[npub]comes frommakeProfilePath(pubkey).- State that shouldn't be a route goes in the query string:
?at=(jump to a message),?topic=,?board=,?page=, and?h=&shareToChat=1on article create.
Pages read params once into constants:
const {relay, address} = $page.params as MakeNonOptional<typeof $page.params>
const url = decodeRelay(relay)
That is safe because the layouts remount their children when a param changes.
spaces/+layout.svelte keys on relay, spaces/[relay]/+layout.svelte and
chat/+layout.svelte key on the pathname, [h]/+layout.svelte keys on ?at=, and
people/[npub]/+layout.svelte keys on npub. A new param-bearing route needs the same {#key},
or its page has to derive from $page instead. Routes read $page from the deprecated
$app/stores; only the two modal modules use page from $app/state.
Layouts
src/routes/+layout.svelteruns the boot sequence, rendersAppContainerandModalContainer, and setsdocument.titlefromgetPageTitle.AppContainergates the route on a signed-in user (flotilla-state), showingLandingin anoEscapedialog otherwise.spaces/[relay]/+layout.sveltegates the space, pushing one modal at a time, once per url:SpaceRedirect(the NIP-11 document carries aredirect_to),SpaceJoin(the space is not in the user's room list, checked after aforceLoad),SpaceAuthError,SpaceTrustRelay. It rendersSecondaryNavwithSpaceMenuand wraps the page inPage, except at the space root.settings/+layout.svelteis aSecondaryNavofSecondaryNavItemlinks. A new settings page needs an entry there.chat/+layout.svelteis the conversation list, plus a FAB to start a chat.
Since the space layout supplies Page, a space page renders a SpaceBar and a PageContent:
<SpaceBar>
{#snippet leading()}<Icon icon={CaseMinimalistic} />{/snippet}
{#snippet title()}<strong>Classifieds</strong>{/snippet}
{#snippet action()}
<Button class="button button-primary button-sm" onclick={createClassified}>Create</Button>
{/snippet}
</SpaceBar>
<PageContent bind:element class="@container flex flex-col gap-3 p-2 sm:gap-4 sm:p-4">
Detail pages also pass back, a handler that calls history.back(), which SpaceBar shows on
mobile. Pages outside a space use Page, PageBar and PageContent themselves.
URL builders, titles, deep links
src/app/routes.ts is the only place paths are built:
makeSpacePath,makeRoomPath,makeSpaceChatPath,makeProfilePath,makeChatPath, and one builder per content page (makeThreadPath,makeClassifiedPath,makeArticlePath,makeCalendarPath,makeGoalPath,makePollPath,makeLibraryPath,makeArticleCreatePath).makeContentPath(url, kind, idOrAddress)maps a kind to its page.makeEventPathbuilds on it for any event, covering DMs, room messages (?at=) and comments (through the parent'sK,AandEtags), and falls back toentityLinkfromsrc/app/env.ts, an external link.goToEvent(event)scrolls to the event if it is already rendered with adata-eventattribute and navigates otherwise;makeEventPermalinkis the shareable form.goToSpace(url)goes tomakeSpaceEntryPath(url): the last page visited in that space, whichsetupHistoryrecords, else chat or about.goToChat(pubkeys)checks for messaging relays first, pushingChatEnableif there are none.
src/app/title.ts maps route ids to tab titles. A new static route needs a staticTitles entry,
and a new event detail route needs an eventRoutes entry and a branch in getPageTitle.
Without one the tab shows only PLATFORM_NAME.
Deep links arrive in handleDeepLink in the root layout: push-notification links (?relay=&id=),
the iOS share extension (the share host), signer returns (x-callback-url), and otherwise a
plain path. Nostr entities go through /[bech32], which sends profiles to makeProfilePath, and
loads events before calling goToEvent.
Adding a space content page
src/routes/spaces/[relay]/<name>/+page.svelte, plus[id]or[address]for detail.make<Name>Pathinsrc/app/routes.ts, and a case inmakeContentPath, so notifications and permalinks land there.- Titles in
src/app/title.ts. - A
SecondaryNavIteminSpaceMenuNavItems.svelte. Content entries appear only once the space holds that kind, which comes fromCONTENT_KINDSinsrc/app/content.ts.
The registries outside the view layer, including the link-preview server, are in flotilla-architecture and flotilla-model.
Navigation, modals, popovers, toasts
navigate, not goto
navigate(path, {replaceState, keepModal}) in src/app/modal.ts wraps goto. With a modal open
it replaces the modal's history entry, so Back doesn't reopen it, and keepModal changes the
page underneath while leaving the stack open (goToHome uses it). Link calls navigate and
stops propagation, so a link inside a clickable card only follows the link. Plain goto survives
in pages for query-string updates (replaceState, noScroll, keepFocus) and redirects, where
no modal can be open.
pushModal
pushModal(ClassifiedEdit, {url, event}) // replaces any open modals
pushModal(WalletConnect, {}, {nested: true}) // stacks on top of the current one
pushModal(EmojiPicker, {onClick: onEmoji}, {replaceState: true}) // swaps out the current one
pushModal(SpaceMenuDrawer, {url: spaceUrl}, {drawer: true}) // side drawer instead of a dialog
Open modal ids live in SvelteKit page state (App.PageState.modals in src/app.d.ts), the
components live in a $state record in src/app/modal.svelte.ts, and ModalContainer mounts
each one inside Dialog or Drawer. Each modal owns a history entry, so Back closes it, and a
push without nested replaces the whole stack. replaceState suits mobile menus that open a
follow-up (RoomItemMenuMobile) and multi-step flows. noEscape removes the close button and
ignores Escape and the backdrop; the space gates use it. ModalOptions.path is never read.
Modal components take identifiers like any other component. Dialog supplies the chrome, and
/join renders SpaceInviteAccept in a Dialog directly. The usual shape:
<Modal tag="form" onsubmit={preventDefault(submit)}>
<ModalBody>
<ModalHeader>
<ModalTitle>Create a Room</ModalTitle>
<ModalSubtitle>On <span class="text-primary">{displayRelayUrl(url)}</span></ModalSubtitle>
</ModalHeader>
...fields
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>Go back</Button>
<Button type="submit" class="button button-primary" disabled={loading}>
<Spinner {loading}>Create Room</Spinner>
</Button>
</ModalFooter>
</Modal>
A modal closes itself with history.back(), in 116 call sites. clearModals() ends a flow that
may sit on top of other modals, such as a delete confirmed from a menu. popModal() closes the
modal before doing something else in the same handler, where history.back() would race it:
ProfileDetail pops before goToChat, and SearchBody pops before goToEvent.
For yes/no questions, push Confirm from lib with {title, message, confirm}; it runs confirm
behind its own loading state. Reusable confirmations get a *Confirm wrapper
(EventDeleteConfirm).
Popover menus
MenuButton renders a Tippy popover around the component you pass, adding an onClick prop
that hides it:
<MenuButton component={ChatMenu} aria-label="Chat options" />
EventMenu attaches onClick to its <ul>, so any item closes the popover, and each item
usually pushes a modal. On mobile, rows push a *MenuMobile modal instead: RoomItem pushes
RoomItemMenuMobile on tap.
Toasts
pushToast in src/app/toast.ts shows one toast at a time:
pushToast({message: "Role created!"})
pushToast({theme: "error", message, action: {message: "Details", onclick}})
pushToast({timeout: 30_000, children: {component: ThunkToast, props: {thunk}}})
A children component receives the toast as a prop, so it can pop itself. clip(value) copies
to the clipboard and toasts.
lib vs app components
src/lib/components knows nothing about the app instance, stores, or nostr kinds. Its components
import third-party libraries, @welshman/lib helpers, @lib/* and @assets/*, plus
$app/stores in PrimaryNavItem and SecondaryNavItem, which highlight the active path. What
lives there:
- page chrome:
Page,PageBar,PageContent,SecondaryNav*,PrimaryNavItem,FAB - modal chrome:
Dialog,Drawer,Modal,ModalBody,ModalHeader,ModalTitle,ModalSubtitle,ModalFooter,Confirm - controls:
Button,Link,Field,FieldInline,Input,InputList,ToggleInput,DateTimeInput,ImagesInput,IconInput,EmojiPicker,MenuButton - display and lists:
Icon,Badge,Card,Divider,Spinner,Tooltip,Tippy,Popover,Cv,VirtualList,Masonry,DragList,ScrollToTop - the CSS component families (
button.css,card.css, …) and the theme tokens
Anything that takes an identifier and reads a store, publishes, or knows a kind is an app
component. A lib component that needs app behavior takes it as a prop, such as MenuButton's
component. Three lib components import @app anyway; flotilla-architecture lists them under
its layer exceptions.
src/app/components is flat, and hosting/ is the only subdirectory it has ever had, brought in
whole by the caravel port (cf938c63) with names that would blur into the flat Relay* family.
New components go in the flat folder.
Naming
Names are <Entity><Qualifier>: the prefix says what it's about, the suffix what it does, which
keeps families adjacent in a flat listing. ClassifiedActions, ClassifiedCreate,
ClassifiedEdit, ClassifiedForm, ClassifiedItem, ClassifiedStatus.
Name a modal for what it does (SpaceJoin, ClassifiedCreate, EventDeleteConfirm); only a
handful carry a Modal or Dialog suffix. The suffix vocabulary, the prefix families, and the
names that break the pattern are in naming.md.
Props
Identifiers first, the event when you have it
url is a prop in 159 components, h in 32 and pubkey in 28. Relays, rooms and profiles have
stores, so a component takes the key and looks up the rest: RoomName takes {url, h} and reads
$rooms.forRoom(url, h), ProfileName takes pubkey and reads $profiles.display.
Events are the exception, and 66 components take event: TrustedEvent, since the parent already
holds the event from a feed or a deriveEvent. An event prop usually travels with url, the
relay it lives on, which routes its replies and reactions, and with context: FeedContext, the
shared loader below. A component takes a pointer instead only when it has to load the event
itself, as ContentQuote does. Anything with neither a store nor an event is passed as its
domain reader: RoleEdit takes role: RelayRoleReader.
Identifier props are read once, const room = $rooms.forRoom(url, h), since pages remount on a
param change and lists key by id. A prop that does change while mounted needs $derived:
ClassifiedActions reads its event that way, because editing a listing hands it a new version.
The rest of the vocabulary
- Callbacks are camelCase
on*:onSubmit,onClose,onCancel,onReply,onSelect,onResolved, plus theonClickthat closes a popover.RoomFormandProfileEditFormtake a lowercaseonsubmit, a leftover. - Steps in a flow take
next, and the signup steps addstepandtotalSteps. - Snippets:
children;headerandfooteron forms;customActions, which adds items toEventActions,EventMenuandProfileMenu;leading,titleandactiononSpaceBar. A snippet can take arguments, asRoomForm'sfooter: Snippet<[{loading: boolean}]>does. $bindableis for input-like components:valueonTopicMultiSelectandProfileMultiSelect,elementonPageContent,notificationsonSpaceJoinNotifications.- Display flags are fine (
showRoom,showActivity,hideZap,class); derived data is not. - 24 app components and 20 lib components declare
interface Props, against 232 usingtype.
Reading state in a component
Components read plugins through the usePlugin stores exported from src/app/core.ts
($profiles, $relays, $rooms, $roomLists, $network, $thunks, $deletes,
$relayManagement, …). $app.use(X) covers plugins with no export, such as Zappers,
Pinboards and Feeds; flotilla-state has the rules about which to use where.
How you bind depends on what the method returns:
<script lang="ts">
const room = $rooms.forRoom(url, h) // Readable: one, forRoom
const display = $profiles.display(pubkey, removeUndefined([url])).$ // Projection: take .$
const shouldProtect = $relays.hasNip(url, 70) // Promise: load, hasNip
</script>
{$room?.meta?.name() || h} · {$display}
In a handler, call .get() on a projection instead of subscribing. The app modules add derive*
factories over the same data: deriveEvent and deriveEventsById in src/app/repository.ts,
deriveSpaceSupportedMethods in src/app/management.ts, deriveRelayAuthError in
src/app/access.ts. Call them at the top of the script with fixed arguments, and wrap one in
$derived only when its arguments change, as the thread page does for filters that wait on the
root event.
Read an event's tags through a domain reader, $derived(reader(Classified)(event)), with
reader from @app/core. $user throws signed out, so use it only under the login gate.
Loading from the network
A page or component loads what it displays; a layout loads what it gates on. Background sync for
data every page needs lives in src/app/sync.ts.
One entity, or a handful of lists: call plugin load in onMount, and toast on failure, as
people/[npub]/+page.svelte does for the profile, relay list, follow, pin, room and messaging
lists before loading the author's outbox with $network.loadLenient.
A detail page: deriveEvent(address, [url]) loads when nothing local matches. While it is
empty, seven pages show a spinner that turns into a failure message:
{#await sleep(5000)}
<Spinner loading>Loading listing...</Spinner>
{:then}
<p>Failed to load classified listing.</p>
{/await}
Related events: request them, abort on teardown, and read them back from the repository with
deriveEventsAsc(deriveEventsById(filters)).
onMount(() => {
const controller = new AbortController()
$network.request({relays, filters, signal: controller.signal})
return () => controller.abort()
})
That is EventComments. The thread detail page does the same in an $effect.
A list that pages as you scroll: makeFeed, makeScrollLoader and makeFeedContext from
src/app/feeds.ts. Every space list page uses them, as do HomeNetwork, ProfilePageNotes and
RoomChat. The threads and classifieds pages are the reference:
const context = makeFeedContext({relays: [url]})
onDestroy(context.cleanup)
let older: Maybe<ReturnType<typeof makeScrollLoader>> = $state()
let element: HTMLElement | undefined = $state()
let events: Readable<TrustedEvent[]> = $state(readable([]))
const loading = $derived(isFeedLoading($older))
const exhausted = $derived($older?.status === "exhausted")
onMount(() => {
const feed = makeFeed({relays: [url], onEvent: context.add, filters})
events = feed.events
older = makeScrollLoader(element!, feed.loadOlder)
return () => {
older?.stop()
feed.cleanup()
}
})
The feed is built in onMount because the loader needs the bound scroll element from
<PageContent bind:element>. context.add batches the reactions, comments and deletions for
every event the feed yields, rows get the same context, and their *Actions read
context.related(event) and context.deleted(event). Close the list with <Spinner {loading}>
and an {#if} chain over loading, empty and exhausted. Calendars use
makeCalendarFeed, which pages by date tag rather than created_at.
ProfileFeed still drives welshman's FeedController through
$app.use(Feeds).makeFeedController and createScroller, but 27fa25c3 moved the home feed onto
the app helpers, which is the direction for new lists. Only RoomChat virtualizes; other long
lists wrap each row's root in Cv, which applies content-visibility and paints two viewports
ahead. All ten *Item components that appear in a list use it.
Mutations from a component
flotilla-state covers the writer → command → thunk path. The component around it owns a
loading flag, awaits the first error, toasts it, and closes:
const submit = async () => {
loading = true
try {
const thunk = await command(eventWriter).then(publish)
const error = await thunk.waitForError()
if (error) {
return pushToast({theme: "error", message: error})
}
history.back()
} finally {
loading = false
}
}
Bind the flag to the button with disabled={loading} and <Spinner {loading}>. Plugin mutators
return a Command as well: $deletes.deleteEvent(event, w => w.setProtected(protect)) then
.publishToRelays([url]) in EventDeleteConfirm, or $rooms.createRoom(url, room) then
.publish() in RoomForm. NIP-86 calls resolve to {error} instead of a thunk:
$relayManagement.forUrl(url).createRole(...) in RoleCreate.
The hosting backend isn't nostr. Toast a readable message, and console.error anything that
isn't a HostingError, as HomeHosting and hosting/CustomDomainModal do. Publishes are
optimistic, so rows show progress in place: the seven per-kind *Actions components wrap their
contents in ThunkStatusOrDeleted, and chat pushes a ThunkToast.
Forms
Field puts a label above its control, with optional secondary and info snippets.
FieldInline puts the label left and the control right, as settings and detail rows do. Controls
are plain elements styled by class (<label class="input …">, <select class="select input">),
or bindable inputs from lib (ImagesInput, IconInput) and app (TopicMultiSelect).
Create/edit pairs come in three shapes:
- Fields only.
RoleFormexportsValuesfrom<script module>, takesinitialValues?: Partial<Values>,loadingandonSubmit(values), and renders the footer.RoleCreateandRoleEditeach own their mutation, toast and close.hosting/RelayFormdoes the same withPick<HostedRelay, …>. Use this shape when create and edit differ. - The form owns the mutation.
RoomFormcreates, edits and joins, whileRoomCreateandRoomEditsupplyheader,footer({loading})and where to go next. - The form owns the mutation and a draft.
ClassifiedFormpersists fields withDraftKeyand republishes the samedon edit.ClassifiedCreatepasses a header, andClassifiedEditturns a reader intoinitialValues. ItsValuestype stays local, soClassifiedEditrestates the shape; export it instead.
Rich text uses makeEditor from src/app/editor with EditorContent.
Styling
- Classes come from the component families in
src/lib/components/*.css, whichtheme.cssimports:buttonwithbutton-primary|neutral|link|ghost|errorandbutton-sm|xs|circle|square,card,badge,input,select,textarea,menu. These are flotilla's own; daisyUI is not installed. - Colors are the semantic tokens registered in
base.css:bg-surface,bg-surface-more,text-content,text-content-muted,border-line,text-primary,text-error. The clay, flat and navy themes supply the values throughdata-fl-theme. - Seven
class:directives remain as leftovers; everything else builds classes withcx. - Container queries go on the
PageContent, as in@2xl:grid-cols-2for the classifieds grid. - Icons are
import X from "@assets/icons/<name>.svg?dataurl"with<Icon icon={X} size={4} />, wheresizecounts 4px steps. List entries animate within:flyfrom@lib/transition.
Related skills
flotilla-architecture: layer rules and exceptions, thesrc/appmodules, boot, placementflotilla-state: plugin stores,derive*stores, drafts, settings, thunks and commandsflotilla-model: spaces, rooms, NIP-43/29/86, content kinds, and where events are publishedwelshman-app: plugins, projections,Command, thunks,Feedswelshman-store:deriveEventsById,deriveEventsAsc, the other repository storeswelshman-domain: the readers and writers behindreaderandwriterwelshman-feeds:FeedController, still used byProfileFeedwelshman-editor: the composer behindmakeEditor