Screen Primitives
The settings, overview, episode-workspace and guest screens are all assembled from the same small set of components. This page is the index of them, so a new screen composes rather than hand-rolls. The composition rules themselves (rail, card stack, explicit save, mobile sheets) live in the design system; this is the map from a rule to the component that implements it.
The set
| Component | Where | What it is |
|---|---|---|
SettingsPageNav | $lib/components/settings/ | The 196px deep-link rail AND its mobile form. Renders SectionJumper itself and runs the scrollspy |
StepRail | $lib/components/shared/ | The stepped counterpart: a 196px step rail AND its mobile "Jump to a step" sheet, for create wizards |
SettingsSection | $lib/components/settings/ | Card shell: 30px icon puck, bold title, one explaining line, optional right-aligned status |
SettingsRow | $lib/components/settings/ | 216px label column plus control column. Help text sits under the LABEL, never under the input |
SettingsSaveBar | $lib/components/settings/ | The sticky explicit-save bar, its saved confirmation, and the blocked (validation-hold) state |
SurfaceCard | $lib/components/surface/ | Card shell with a footer note strip, keyed by SurfaceIcon name so a server load can build a view model |
TonePill / IconPuck | $lib/components/surface/ | Fact-stating pill and tinted icon tile |
EmptyState | $lib/components/surface/ | Puck, title, ONE line, optional CTA and reassurance |
SidePanel | $lib/components/shared/ | The write flow that opens beside the page |
SectionJumper / ui/sheet | $lib/components/shared/, $lib/components/ui/ | Mobile section picker and the sheets it opens |
CardNote | $lib/components/settings/ | The card's footer note strip: one consequence-and-timing sentence, optionally one link |
ToggleRow | $lib/components/settings/ | SettingsRow whose control is one switch. dense for the stacked sub-rows inside a card |
DangerZone / DangerAction | $lib/components/settings/ | The walled terminal section, and one irreversible action inside it with an inline confirm |
ScreenHeader | $lib/components/surface/ | Identity header with a tab bar on its bottom edge. Takes its art as a snippet, so any screen can use it |
AccountScreenHeader | $lib/components/settings/ | ScreenHeader with the account avatar as its art. The four account screens' own wrapper |
StepRail
The stepped flows' navigation, built for the Create episode redesign and deliberately generic so the booking-link create flow (the same three-step pattern) can adopt it rather than growing a second rail. It renders both forms itself, like SettingsPageNav: a sticky 196px rail on desktop and a full-width step button opening a "Jump to a step" bottom sheet on mobile.
Each RailStep carries a meta status ("Required", "Optional", a count, "Date needed") with a metaTone (accent paints it brand purple for the things still needing attention) and a done flag that swaps the number for a tick. The footer snippet renders desktop-only content under the step list; the Create episode page puts its "The episode so far" summary card there.
Two behaviours worth knowing:
- Steps are navigation, not gates. The rail never blocks a jump; the page decides what the primary action needs (Create episode requires a title and a recording date, from any step).
- A sheet pick fires
onSelectonly after the sheet has fully closed (theSectionJumperpattern), so the page's follow-up scroll is not swallowed by the sheet's scroll lock.
The step panels a StepRail navigates must stay mounted and be hidden with CSS when the page is a single <form>: a step that unmounts its fields silently drops them from the FormData.
SettingsSaveBar and held saves
The bar has an optional blocked state ({ title, detail, targetId }) for pages whose draft can be invalid in ways the server would reject or that would destroy data (the booking-link editor's question set, its paired platform URL). While blocked, the dot turns amber, the bar names the thing to fix, Take me there scrolls to targetId, and Save is disabled. A held save is never silence: the same rule that used to make an autosave quietly not fire now has a name, a place, and a button. Pages with no validation of their own simply never pass blocked.
SettingsSection or SurfaceCard?
They draw the same card. The difference is how they take their icon:
SettingsSectiontakes a lucide component (icon={Star}), so it can render any icon in the set. Reach for it when the icons are chosen in the component.SurfaceCardtakes aSurfaceIconname (icon="mic"), which is serialisable, so a server load can decide the icon. It also has the footernotestrip. Reach for it when the card's content is built in a+page.server.ts.
Adding a name to SurfaceIcon means updating types.ts and icons.ts in lockstep. That is a compile error rather than a test: SURFACE_ICONS is typed Record<SurfaceIcon, IconComponent>, so a name with no icon and an icon with no name both fail pnpm check. If you only need one unusual icon, SettingsSection avoids the question entirely.
EmptyState
Built once so the same absence does not get hand-rolled per screen. It renders a 52px circular muted puck, a title, one line of prose, an optional CTA and an optional reassurance line under it.
Deliberately narrow, per the design system: no feature lists, no illustrations. The one escape hatch is samples, which renders up to two realistic sample outputs as chat-style chips where seeing the output explains the value better than a sentence about it.
An empty state with no CTA is normal, and correct wherever the viewer cannot act on the absence (a guest's profile has no published appearances; only the guest's hosts can change that).
SidePanel
Write flows open beside the page, not inside the card that launched them. A composer that grows inline turns a card into a card inside a card, and the reader loses the context they were reading.
- Right-hand slide-over from
smup, bottom sheet below it. The switch usesMediaQueryfromsvelte/reactivity, which is SSR-safe. - Header is the card header at panel scale: 30px puck, bold title, one explaining line.
- The footer states the consequence of the primary action next to the button ("Nothing is sent until you send it"), rather than leaving it to be discovered afterwards.
footerreplaces the default primary/cancel row entirely, for a panel whose body owns its own submit or steps through more than one action. The Guests page's invite panel does this: its footer swaps between "Next: choose the episode" and "Add to this episode" as its two steps advance.
CardNote, and where banner copy lives now
The 2026 standard deleted every dismissible banner and re-homed the useful sentence into the card it concerns. CardNote is that home, passed to SettingsSection's footer snippet:
{#snippet note()}
<CardNote link={{ label: 'Compare plans', href: '/pricing' }}>
Upgrades start immediately and are charged pro rata. Downgrades start at the end of the
current period, so nothing is lost before then.
</CardNote>
{/snippet}
<SettingsSection id="subscription" title="Subscription" icon={CreditCard} footer={note}>It takes ONE sentence and at most one link. If a card needs two sentences in its footer, the second one usually belongs next to the control it describes.
DangerZone and DangerAction
Two rules are baked into DangerAction rather than left to callers, because both were being re-decided per screen:
- Red only on hover, then again on the confirming click. A control that is solid red at rest trains people to ignore red.
- Confirm inline, never in a modal. The button is replaced in place by the question plus Cancel and the confirming button. Modals are reserved for actions needing a typed confirmation.
disabled renders the action as drawn but inert, for a destructive path whose backend does not exist yet. It is a real disabled attribute rather than a styling class, so the control is skipped by keyboard navigation instead of being focusable and silently doing nothing.
DangerAction has no form of its own. Where the confirm must POST, wrap the DangerZone in the form and have onConfirm call requestSubmit() on it, as settings/ai-connections/+page.svelte does for Disconnect all.
The account shell
/settings, /settings/my-episodes, /settings/ai-connections and /settings/reputation are one shell with four surfaces. Each page renders AccountScreenHeader with the tabs from accountTabs(data.accountTabCounts, data.isGuestOnlyUser), and the counts come from settings/+layout.server.ts.
Two decisions worth knowing before changing it:
- The counts load in the layout, not per page. Loaded per page each tab would show its own number and blank the other three, so the counts would appear and disappear as you moved between the tabs of one bar. SvelteKit does not re-run a layout load when you navigate between its own children, so tab-to-tab navigation costs no further server work.
- The tab ORDER is per user, never per page. A guest-only account leads with My episodes (it is the only item in their sidebar); a podcaster leads with Account. The delivered design contradicted itself here, and an order that changes per page slides the tabs sideways under the pointer as you navigate.
/settings/notifications and /settings/ai-connections/authorize live under the same layout and deliberately do NOT render the header: the first is reached from the sidebar rather than the tab bar, and the second is an OAuth consent screen.
The Guest Network shell
/guest-network, /guest-network/invitations and /guest-network/invitations/sent are the second screen built this way: one ScreenHeader, three tabs from guestNetworkTabs(), counts from guest-network/(network)/+layout.server.ts.
Two differences from the account shell are worth knowing:
- It lives in a
(network)route group./guest-network/[profileId]is a separate design with its own header and no tab bar, so it sits OUTSIDE the group and never pays for the shell's queries. Reach for a group whenever a layout's data serves some children and not others. ScreenHeaderwraps its actions at 390px (w-full sm:w-auto). Sharing one row with them left the identity about 120px wide, and the title truncated to "Guest Netw…" with the metadata stacked into a seven-line column. The account screens pass no actions, which is why the bug did not show up until a second caller existed.
ScreenTab (in $lib/components/surface/screen-tabs.ts) is the shape both bars share. Only one tab per bar should ever carry alert: it means something is waiting on this person, and if everything is urgent then nothing is.
Testing components that use HelpTooltip
Tooltip.Provider is mounted once in the ROOT layout, so any component rendered on a page has it. @testing-library/svelte mounts components bare, so a component containing HelpTooltip throws Context "Tooltip.Provider" not found in a unit test while working in the app.
Render it through src/test/TooltipHarness.svelte rather than mocking $lib/tooltips away:
render(TooltipHarness, { props: { component: MyCard, props: MY_PROPS } });Mocking the tooltip out would hide a real regression if the component were ever rendered outside the provider.
Related
- SvelteKit routing
- Podcast overview for the server-built view-model pattern