| name | hcp-frontend |
| description | HCP frontend design patterns, component conventions, and architecture guide. Use when: writing Svelte components, adding features, refactoring, creating pages, working with remote functions, settings forms, dialogs, or any frontend code in this project.
|
HCP Frontend — Patterns & Conventions
Stack
- SvelteKit 2 + Svelte 5 (runes mode only)
- shadcn-svelte component library (Bits UI primitives)
- Bun as runtime + package manager
- Tailwind CSS for styling
- Quality:
make quality runs eslint + prettier + svelte-check
Project Layout
frontend/src/
├── lib/
│ ├── components/ui/ # Reusable components (shadcn + custom)
│ ├── utils/ # Composables and helpers
│ ├── constants.ts # Enums, role lists, shared constants
│ ├── *.remote.ts # Remote functions (query/command)
│ └── tenant-info.remote.ts, users.remote.ts, etc.
├── routes/
│ ├── (app)/ # Authenticated app routes
│ │ ├── namespaces/ # Namespace list + detail pages
│ │ ├── buckets/ # S3 bucket pages
│ │ ├── users/ # User + group pages
│ │ ├── settings/ # Tenant settings
│ │ └── ...
│ └── login/
Remote Functions
All API calls use SvelteKit remote functions in *.remote.ts files.
export const get_thing = query(schema, async (params) => { ... });
export const update_thing = command(schema, async (params) => { ... });
In components:
let data = $derived(get_thing({ tenant, name }));
let current = $derived(data?.current as Thing);
Mutations refresh queries:
await update_thing({ tenant, body }).updates(data);
Settings Form Pattern
All settings/config forms follow this exact pattern using useSave:
<script lang="ts">
import SaveButton from '$lib/components/ui/save-button.svelte';
import { useSave } from '$lib/utils/use-save.svelte.js';
// 1. Query the data
let data = $derived(get_something({ tenant }));
let current = $derived((data?.current ?? {}) as SomeType);
// 2. Create saver
const saver = useSave({
successMsg: 'Settings updated',
errorMsg: 'Failed to update settings',
});
// 3. Local editable state
let localField = $state('');
// 4. Sync from server (re-runs on data change OR after save)
$effect(() => {
const c = current;
void saver.syncVersion; // <- triggers re-sync after save
localField = c.field ?? '';
});
// 5. Dirty check
let dirty = $derived(localField !== (current.field ?? ''));
</script>
<!-- 6. SaveButton with inline save logic -->
<SaveButton
{dirty}
saving={saver.saving}
onclick={() =>
saver.run(async () => {
if (!data) return;
await update_something({ tenant, body: { field: localField } })
.updates(data);
})}
/>
Key rules:
useSave handles saving state, syncVersion increment, and toast messages
- The
$effect MUST read void saver.syncVersion to re-sync after save
- The save logic goes inline in the
onclick callback, NOT in a separate function
- Use
$state for editable fields, $derived for dirty checks and query results
Dialog Pattern
All create/edit dialogs use FormDialog:
<script lang="ts">
import FormDialog from '$lib/components/ui/form-dialog.svelte';
import { toast } from 'svelte-sonner';
let { open = $bindable(false) }: { open: boolean } = $props();
let error = $state('');
let loading = $state(false);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
loading = true;
error = '';
try {
await create_thing({ ... }).updates(queryData);
toast.success('Created successfully');
open = false;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed';
} finally {
loading = false;
}
}
</script>
<FormDialog bind:open title="Create Thing" {loading} {error} onsubmit={handleSubmit}>
<!-- form fields here -->
</FormDialog>
Reusable Components
Always check these before building new UI:
| Component | Import | Use for |
|---|
SaveButton | $lib/components/ui/save-button.svelte | Settings form save (dirty + loading) |
FormDialog | $lib/components/ui/form-dialog.svelte | Create/edit dialogs with form + error |
CopyableInput | $lib/components/ui/copyable-input.svelte | Readonly value with copy button, optional secret |
CorsEditor | $lib/components/ui/cors-editor.svelte | CORS XML editing with save/delete |
IpListEditor | $lib/components/ui/ip-list-editor.svelte | IP allow/deny lists with badges |
TagInput | $lib/components/ui/tag-input.svelte | Tag/chip editing with add/remove |
ErrorBanner | $lib/components/ui/error-banner.svelte | Error message display |
StatCard | $lib/components/ui/stat-card.svelte | Metric display (bytes, counts) |
StorageProgressBar | $lib/components/ui/storage-progress-bar.svelte | Quota utilization bar |
CardSkeleton | $lib/components/ui/skeleton/card-skeleton.svelte | Loading placeholder for cards |
BulkDeleteDialog | $lib/components/ui/bulk-delete-dialog.svelte | Multi-item delete confirmation |
DeleteConfirmDialog | $lib/components/ui/delete-confirm-dialog.svelte | Single-item delete confirmation |
PageHeader | $lib/components/ui/page-header.svelte | Page title + description + actions |
BackButton | $lib/components/ui/back-button.svelte | Navigation back link |
Utilities
| Utility | Import | Use for |
|---|
useSave() | $lib/utils/use-save.svelte.js | Settings save pattern (saving + syncVersion + toast) |
useDelete() | $lib/utils/use-delete.svelte.js | Delete with confirmation + toast |
formatBytes() | $lib/utils/format.ts | Human-readable byte sizes |
parseQuotaBytes() | $lib/utils/format.ts | Parse "50 GB" to number |
calcQuotaPercent() | $lib/utils/format.ts | Percentage for quota bars |
Section Component Pattern
Namespace/settings pages use section components inside a grid:
<!-- +page.svelte -->
<div class="grid gap-6 lg:grid-cols-2">
<SectionA {tenant} {namespaceName} />
<SectionB {tenant} {namespaceName} />
</div>
Each section is a self-contained Card that:
- Fetches its own data via
$derived(get_something(...))
- Shows a loading skeleton via
{#await data} block
- Manages its own local state and save logic
- Uses
Card.Root > Card.Header > Card.Content > Card.Footer structure
Svelte 5 Rules
- Runes only — no
$:, no export let, no on:click, no <slot>
$state for mutable reactive variables
$derived for computed values (NOT $effect for derivations)
$effect only for syncing from server or external side effects
$props() with TypeScript types for component props
$bindable() for two-way bound props (e.g., open in dialogs)
{#snippet} + {@render} instead of slots
onclick={handler} not on:click={handler}
page.data.tenant from $app/state (NOT $app/stores)
Storybook
Visual component testing with Storybook 10 + Svelte CSF v5.
Commands: make storybook (dev on port 6006) / make build-storybook (static build)
CI: .github/workflows/storybook.yml builds on PRs. On main, docs.yml builds both Zensical docs and Storybook, deploying to GitHub Pages at /storybook/.
Actions panel (Svelte 5): Svelte 5 uses callback props instead of on:event. Use fn() from storybook/test in args for Actions panel logging:
import { fn } from 'storybook/test';
const { Story } = defineMeta({
component: MyButton,
args: { onclick: fn() },
});
Running interaction tests in Storybook:
make storybook → open http://localhost:6006
- Sidebar → Tests > DataTable Interactions (or any story with play functions)
- Bottom panel → click "Interactions" tab to see step-by-step results
- Click the play button (triangle) in the Interactions panel to re-run tests
- Each step shows pass/fail — click a step to inspect its state
Story file convention:
- Co-locate stories next to their component:
button.stories.svelte beside button.svelte
- Stories live in
src/ (required for $lib/ alias resolution) but are excluded from production builds
- Use Svelte CSF format with
defineMeta from @storybook/addon-svelte-csf
Storybook philosophy:
- We test our own custom components — not third-party libraries
- shadcn-svelte components (Button, Badge, etc.) are already tested upstream — do NOT write stories for them unless we've significantly customized their behavior
- Stories focus on components with our business logic and composition (e.g., DataTable, CorsEditor, TagInput, FormDialog)
- If a component is just a thin wrapper around a shadcn-svelte primitive with no custom logic, skip the story
When to add visual stories (.stories.svelte):
- New reusable UI component with custom logic → add key variants
- Modified component props/behavior → update existing story
- Pure display components (progress bars, badges, stat cards) → visual story only, no interaction tests needed
When to add interaction tests (-interactions.stories.ts):
- Component has user input (typing, clicking, form submission)
- Component has state transitions (dirty tracking, loading states, open/close)
- Component has validation logic (duplicates, required fields, format checks)
- Do NOT write interaction tests for pure display components
Story template:
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import MyComponent from './my-component.svelte';
const { Story } = defineMeta({
title: 'UI/MyComponent',
component: MyComponent,
});
</script>
<Story name="Default" args={{ someProp: 'value' }}>
{#snippet template(args)}
<MyComponent {...args} />
{/snippet}
</Story>
For data-table stories: use createSvelteTable with inline mock data, renderComponent/renderSnippet for cells, and toast for action feedback instead of real API calls.
Mocking SvelteKit modules
Use parameters.sveltekit_experimental to mock SvelteKit imports in stories. This is needed when a component uses $app/state, $app/navigation, or $app/forms.
<Story
name="WithPageData"
parameters={{
sveltekit_experimental: {
state: {
page: {
data: { tenant: 'test-tenant' },
},
},
},
}}
/>
Available mocks:
state.page — mock $app/state page data (use this, NOT stores.page)
state.navigating — mock navigation state
state.updated — mock { current: boolean } for update checks
navigation.goto / navigation.invalidate — mock $app/navigation functions (defaults to Actions panel logging)
hrefs — mock link click behavior: { '/path': (to, event) => { ... } } (supports asRegex: true)
forms.enhance — mock $app/forms enhance callback
Interaction Tests (play functions)
For automated UI assertions (click, type, assert), use CSF3 .stories.ts files with play functions. The Svelte CSF addon does not support play functions, so interaction tests are written in TypeScript CSF3 format alongside the .stories.svelte visual stories.
Pattern: create a test harness .svelte component with all mock data inline (no props), then write CSF3 stories that target it:
import type { Meta, StoryObj } from '@storybook/svelte';
import { expect, userEvent, within } from 'storybook/test';
import TestHarness from './my-component-test-harness.svelte';
const meta = {
title: 'Tests/MyComponent',
component: TestHarness,
} satisfies Meta<TestHarness>;
export default meta;
type Story = StoryObj<typeof meta>;
export const FiltersCorrectly: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const input = canvas.getByPlaceholderText('Search...');
await userEvent.type(input, 'query');
await expect(canvas.getByText('expected-result')).toBeInTheDocument();
},
};
Key conventions:
- Test harness files:
*-test-harness.svelte — self-contained, no props, inline mock data
- Interaction story files:
*-interactions.stories.ts — CSF3 format with play functions
- Import
expect, userEvent, within, fn from storybook/test
- Use
within(canvasElement) to scope queries to the story canvas
- See
data-table-interactions.stories.ts for a full example with search, selection, sorting, and bulk actions
Naming Conventions
- Remote files:
*.remote.ts (e.g., namespaces.remote.ts)
- Section components:
ns-*.svelte, settings-*.svelte, user-*.svelte
- Dialog components:
*-dialog.svelte (e.g., bucket-create-dialog.svelte)
- Utilities:
use-*.svelte.ts for composables with reactive state
- Storybook visual stories:
*.stories.svelte (Svelte CSF, co-located with component)
- Storybook interaction tests:
*-interactions.stories.ts (CSF3 with play functions)
- Test harnesses:
*-test-harness.svelte (self-contained wrapper for interaction tests)
- Roles enum:
ADMINISTRATOR, SECURITY, MONITOR, COMPLIANCE
Full-Stack Feature Checklist
When adding a new feature that touches the API, all three layers must be updated:
- Backend endpoint (
backend/app/api/v1/endpoints/) — the FastAPI route
- Mock server (
backend/mock_server/mapi_state.py + fixtures.py) — so the frontend can be tested locally
- Frontend remote function (
frontend/src/lib/*.remote.ts) — the query() or command() call
- Frontend UI — the component(s) using the remote function
- Tests (
backend/tests/) — pytest tests for the endpoint
The mock server is what the frontend develops against. If a backend endpoint
exists but the mock doesn't handle it, the frontend cannot be tested.
Required Skills
When working on frontend code, always activate these skills:
svelte:svelte-core-bestpractices — Svelte 5 reactivity, events, styling
svelte:svelte-code-writer — documentation lookup + autofixer validation
- Svelte MCP server (
@sveltejs/mcp) — use svelte-autofixer tool to validate components, get-documentation for syntax questions
These ensure code follows Svelte 5 runes patterns and Bun conventions.
Run the Svelte autofixer (mcp__plugin_svelte_svelte__svelte-autofixer) on any new or modified .svelte file before finalizing.
Use the shadcn-svelte MCP tools (shadcnSvelteGetTool, shadcnSvelteSearchTool) when looking up component APIs.
Anti-Patterns to Avoid
- Don't duplicate save boilerplate — use
useSave() composable
- Don't duplicate dialog boilerplate — use
FormDialog component
- Don't import
toast directly in settings forms — useSave handles it
- Don't use
$effect to compute derived values — use $derived
- Don't fetch same data in multiple sibling components — lift query to parent
- Don't use
Record<string, unknown> for typed API responses — define types
- Don't use
$app/stores — use $app/state for page data