This document provides guidance for building consistent user interfaces in MUNify DELEGATOR. Follow these patterns to maintain visual consistency and leverage existing components.
Maintenance Note: Keep this guide updated when creating new UI components, modifying existing component APIs/props, changing usage patterns, or deprecating components. Documentation should always reflect the current state of
src/lib/components/.
- Consistency First: Match existing patterns in the codebase before inventing new ones
- DaisyUI Native: Prefer standard DaisyUI components and utilities over custom solutions
- Minimal & Clean: Favor simplicity, whitespace, and clear visual hierarchy
- Svelte 5 Runes: Use
$state,$derived,$effectfor reactivity (never legacy stores)
Components are located in src/lib/components/. Key directories:
Calendar/- Conference calendar display (day views, time markers, entry cards)Form/- Form inputs integrated with sveltekit-superformsDashboard/- Dashboard section layouts and widgetsDataTable/- Searchable, sortable data tablesNavMenu/- Sidebar navigation componentsTabs/- Tab navigationDelegationStats/- Statistics display widgetsInfoGrid/- Key-value pair display gridsCharts/- ECharts-based visualizationsPaperHub/- Paper management components including statisticsSurvey/- Survey answer modal and compact survey cards for the dashboard
src/lib/components/PaperHub/DetailedPaperStats.svelte
Displays comprehensive paper statistics with multiple charts and gauges for the Paper Hub dashboard.
interface Paper {
type: PaperType$options; // 'POSITION_PAPER' | 'WORKING_PAPER' | 'INTRODUCTION_PAPER'
status: PaperStatus$options; // 'DRAFT' | 'SUBMITTED' | 'REVISED' | 'CHANGES_REQUESTED' | 'ACCEPTED'
versions: Array<{ reviews: Array<{ id: string }> }>;
}
interface CommitteeWithPapers {
name: string; // Full committee name
abbreviation: string; // Short committee code (e.g., "GA", "SC")
papers: Paper[];
}
interface Props {
allPapers: Paper[];
committeesWithPapers: CommitteeWithPapers[];
}<script lang="ts">
import DetailedPaperStats from '$lib/components/PaperHub/DetailedPaperStats.svelte';
// allPapers: flat array of all papers across committees
// committeesWithPapers: array of committees with their papers grouped
let { allPapers, committeesWithPapers } = $props();
</script>
{#if allPapers.length > 0}
<DetailedPaperStats {allPapers} {committeesWithPapers} />
{/if}This component uses the following chart components from $lib/components/Charts/ECharts/:
| Component | Purpose |
|---|---|
BarChart |
Papers by type distribution |
MultiSeriesBarChart |
Status breakdown by paper type (stacked) |
GaugeChart |
Review progress and acceptance rate gauges |
EChartsBase |
Committee breakdown horizontal bar chart |
- Summary stats row (total papers, with/without reviews, accepted)
- Review progress gauge (papers with at least one review)
- Acceptance rate gauge (accepted papers / non-draft papers)
- Papers by type bar chart
- Status by type stacked bar chart
- Committee breakdown with grouped stacked horizontal bars
Components in src/lib/components/TeamManagement/ for managing team invitations.
src/lib/components/TeamManagement/InviteTeamMembersModal.svelte
Modal for inviting team members via email. Supports batch email input, status checking, and role assignment.
interface Props {
open: boolean; // Controls modal visibility (bindable)
conferenceId: string; // Conference to invite members to
}<script lang="ts">
import InviteTeamMembersModal from '$lib/components/TeamManagement/InviteTeamMembersModal.svelte';
let inviteModalOpen = $state(false);
</script>
<button class="btn btn-primary" onclick={() => (inviteModalOpen = true)}>
Invite Team Members
</button>
<InviteTeamMembersModal bind:open={inviteModalOpen} conferenceId={data.conferenceId} />- Two-step flow: enter emails → review and assign roles
- Email status checking (existing user, new user, pending invitation, already member)
- Role assignment per invitee (MEMBER, REVIEWER, PARTICIPANT_CARE, TEAM_COORDINATOR, PROJECT_MANAGEMENT)
- External domain warning when inviting non-organization emails
- Batch email parsing with comma/semicolon/newline separators
src/lib/components/TeamManagement/PendingInvitationsTable.svelte
Table displaying pending team member invitations with actions.
interface Invitation {
id: string;
email: string;
role: string;
expiresAt: string;
userExists: boolean;
invitedBy: {
given_name: string;
family_name: string;
};
}
interface Props {
invitations: Invitation[];
}<script lang="ts">
import PendingInvitationsTable from '$lib/components/TeamManagement/PendingInvitationsTable.svelte';
</script>
<PendingInvitationsTable invitations={data.pendingInvitations} />- Displays email, role, status (account exists/new user), expiration date, and inviter
- Expiration status highlighting (expired invitations shown with reduced opacity)
- Actions: copy invitation link, resend email, revoke invitation
- Automatic cache invalidation after actions
Components in src/lib/components/Calendar/ for displaying conference calendar schedules.
src/lib/components/Calendar/CalendarDisplay.svelte
Main calendar container that renders day tabs (small screens) or side-by-side columns (3xl+). Handles day selection, track filtering, and entry click → drawer.
interface Props {
days: Day[]; // Array of calendar days with tracks and entries
timezone?: string; // IANA timezone (default: 'UTC') — controls "now" marker and today detection
}<script lang="ts">
import CalendarDisplay from '$lib/components/Calendar/CalendarDisplay.svelte';
</script>
<CalendarDisplay days={previewDays} timezone="Europe/Berlin" />- Responsive layout: tabs on small screens, side-by-side grid on 3xl+
- Automatic "today" tab selection using conference timezone
- Per-day track filtering
- Entry click opens
CalendarEntryDrawerwith details
src/lib/components/Calendar/CalendarDayView.svelte
Renders a single day's timeline with hour grid, entries positioned by time, and a live "now" marker.
interface Props {
dayName: string;
date: Date;
tracks: Track[];
entries: Entry[];
filterTrackId?: string | null;
timezone?: string; // Passed to CalendarTimeMarker
onEntryClick?: (entry: Entry) => void;
}src/lib/components/Calendar/CalendarTimeMarker.svelte
Displays a red "now" line on the calendar timeline. Uses Intl.DateTimeFormat with conference timezone to compute position.
interface Props {
startHour: number;
endHour: number;
hourHeight: number;
timezone?: string; // IANA timezone (default: 'UTC')
}src/lib/components/Calendar/CalendarEntryCard.svelte
Renders a single calendar entry as a colored card positioned on the timeline. Shows icon, name, time range, room, and track.
src/lib/components/Calendar/CalendarEntryDrawer.svelte
Slide-out drawer showing full entry details including place information, map, and site plan.
src/lib/components/Kbd.svelte
Renders a keyboard shortcut hint with OS-aware modifier formatting. On macOS, replaces modifier names with symbols (alt → ⌥, shift → ⇧, ctrl → ⌃, enter → ↵). On Windows/Linux, keeps text as-is. SSR-safe (defaults to text modifiers).
| Prop | Type | Description |
|---|---|---|
hotkey |
string |
Hotkey string, e.g. "alt+a" |
size |
'xs' | 'sm' |
DaisyUI kbd size (default: 'sm') |
<script lang="ts">
import Kbd from '$lib/components/Kbd.svelte';
</script>
<!-- In a button -->
<button class="btn btn-primary">
Save <Kbd hotkey="alt+a" />
</button>
<!-- Small size for inline badges -->
<span class="hidden sm:inline-block"><Kbd hotkey="alt+n" size="xs" /></span>
<!-- Compound shortcuts -->
<Kbd hotkey="shift+enter" />Forms use sveltekit-superforms for validation and state management. Always structure forms consistently.
Always wrap related form inputs with FormFieldset to provide visual grouping:
<script lang="ts">
import FormFieldset from '$lib/components/Form/FormFieldset.svelte';
import FormTextInput from '$lib/components/Form/FormTextInput.svelte';
import FormSelect from '$lib/components/Form/FormSelect.svelte';
</script>
<FormFieldset title="Personal Information">
<FormTextInput {form} name="firstName" label="First Name" />
<FormTextInput {form} name="lastName" label="Last Name" />
<FormTextInput {form} name="email" label="Email" type="email" />
</FormFieldset>
<FormFieldset title="Preferences">
<FormSelect {form} name="language" label="Language" options={languageOptions} />
</FormFieldset>| Component | Purpose | Key Props |
|---|---|---|
Form |
Form wrapper with submit button | form, showSubmitButton, action |
FormFieldset |
Visual grouping with legend | title |
FormTextInput |
Text/email/password input | form, name, label, type, placeholder |
FormTextArea |
Multi-line text | form, name, label |
FormSelect |
Dropdown select | form, name, label, options |
FormCheckbox |
Checkbox toggle | form, name, label |
FormDateTimeInput |
Date/time picker | form, name, label |
FormFile |
File upload | form, name, label |
FormSubmitButton |
Submit with loading state | form, disabled, loading |
<script lang="ts">
import Form from '$lib/components/Form/Form.svelte';
import FormFieldset from '$lib/components/Form/FormFieldset.svelte';
import FormTextInput from '$lib/components/Form/FormTextInput.svelte';
import FormSelect from '$lib/components/Form/FormSelect.svelte';
import FormCheckbox from '$lib/components/Form/FormCheckbox.svelte';
import { superForm } from 'sveltekit-superforms';
let { data } = $props();
const form = superForm(data.form);
</script>
<Form {form}>
<FormFieldset title="Account Details">
<FormTextInput {form} name="username" label="Username" placeholder="Enter username" />
<FormTextInput {form} name="email" label="Email" type="email" />
</FormFieldset>
<FormFieldset title="Settings">
<FormSelect
{form}
name="role"
label="Role"
options={[
{ value: 'user', label: 'User' },
{ value: 'admin', label: 'Admin' }
]}
/>
<FormCheckbox {form} name="notifications" label="Enable notifications" />
</FormFieldset>
</Form>Use Modal for dialogs. It handles backdrop clicks and accessibility.
| Prop | Type | Description |
|---|---|---|
open |
boolean (bindable) |
Controls visibility |
title |
string |
Modal title |
fullWidth |
boolean |
Expand to 90% width |
children |
Snippet |
Modal body content |
action |
Snippet |
Footer actions (buttons) |
onclose |
() => void |
Callback when closed |
<script lang="ts">
import Modal from '$lib/components/Modal.svelte';
import FormFieldset from '$lib/components/Form/FormFieldset.svelte';
let modalOpen = $state(false);
</script>
<button class="btn btn-primary" onclick={() => (modalOpen = true)}>Open Modal</button>
<Modal bind:open={modalOpen} title="Edit Item">
<FormFieldset title="Item Details">
<label class="form-control w-full">
<div class="label"><span class="label-text">Name</span></div>
<input type="text" class="input input-bordered w-full" placeholder="Enter name" />
</label>
</FormFieldset>
{#snippet action()}
<button class="btn" onclick={() => (modalOpen = false)}>Cancel</button>
<button class="btn btn-primary" onclick={handleSave}>Save</button>
{/snippet}
</Modal>When using superforms inside a modal, integrate the Form component:
<Modal bind:open={modalOpen} title="Create Item">
<Form {form} showSubmitButton={false}>
<FormFieldset title="Details">
<FormTextInput {form} name="name" label="Name" />
<FormTextInput {form} name="description" label="Description" />
</FormFieldset>
</Form>
{#snippet action()}
<button class="btn" onclick={() => (modalOpen = false)}>Cancel</button>
<button class="btn btn-primary" onclick={submitForm}>Create</button>
{/snippet}
</Modal>Use Drawer for slide-out panels (e.g., detail views, edit forms).
| Prop | Type | Description |
|---|---|---|
open |
boolean (bindable) |
Controls visibility |
category |
string |
Category label |
title |
string |
Panel title |
loading |
boolean |
Show loading spinner |
width |
string |
Panel width (default: 34rem) |
onClose |
() => void |
Callback when closed |
<script lang="ts">
import Drawer from '$lib/components/Drawer.svelte';
let drawerOpen = $state(false);
let loading = $state(false);
</script>
<button class="btn" onclick={() => (drawerOpen = true)}>View Details</button>
<Drawer bind:open={drawerOpen} category="Delegation" title="Germany" {loading}>
<div class="flex flex-col gap-4">
<p>Delegation details go here...</p>
</div>
</Drawer>Use TopDrawer for overlay panels that slide down from the top of the screen. Built on vaul-svelte, it provides a gesture-friendly drawer with drag-to-close support. Used in management tool pages (accessFlow, postalRegistration, payments) for showing scanned/searched item details.
| Prop | Type | Description |
|---|---|---|
open |
boolean (bindable) |
Controls visibility |
maxWidth |
string |
Max width class (default: 'max-w-2xl') |
title |
string |
Header title text |
titleIcon |
string |
FontAwesome icon class (e.g. 'fa-id-badge') |
headerActions |
Snippet |
Buttons in header (profile link, close) |
children |
Snippet |
Scrollable content area |
footer |
Snippet |
Sticky footer with action buttons |
<script lang="ts">
import TopDrawer from '$lib/components/TopDrawer.svelte';
let drawerOpen = $state(false);
</script>
<TopDrawer bind:open={drawerOpen} title="Identity Check" titleIcon="fa-id-badge">
{#snippet headerActions()}
<button class="btn btn-ghost btn-sm btn-square" onclick={() => (drawerOpen = false)}>
<i class="fa-solid fa-xmark text-lg"></i>
</button>
{/snippet}
<p>Scrollable content goes here...</p>
{#snippet footer()}
<button class="btn btn-primary flex-1">Save & Next</button>
<button class="btn btn-error" onclick={() => (drawerOpen = false)}>Close</button>
{/snippet}
</TopDrawer>Note: TopDrawer is different from Drawer — TopDrawer uses vaul-svelte for gesture/swipe support and slides from the top; Drawer is a right-side slide-out panel.
Use BarcodeScanner for pages that need barcode scanning via camera or manual text input. Encapsulates camera management, barcode detection, device switching, and manual input fallback.
| Prop | Type | Description |
|---|---|---|
scannedCode |
string | null (bindable) |
The detected/entered code |
persistKey |
string |
localStorage key for camera preference |
barcodeFormats |
BarcodeFormat[] |
Formats to detect (default: ['data_matrix', 'code_128']) |
manualPlaceholder |
string |
Placeholder for manual input field |
scanPromptText |
string |
Prompt shown while camera is scanning |
cameraZIndex |
string |
z-index class for camera preview (default: 'z-30') |
extraControls |
Snippet |
Optional controls between camera settings and input |
reset()— Clears scanned code and restarts camera or refocuses manual input
<script lang="ts">
import BarcodeScanner from '$lib/components/Scanner/BarcodeScanner.svelte';
import { queryParameters } from 'sveltekit-search-params';
let params = queryParameters({ queryUserId: true });
let scannerRef: BarcodeScanner;
</script>
<BarcodeScanner
bind:this={scannerRef}
bind:scannedCode={$params.queryUserId}
barcodeFormats={['data_matrix', 'code_128']}
persistKey="useCameraForMyPage"
manualPlaceholder="Enter code..."
scanPromptText="Present the barcode..."
/>
<!-- Call scannerRef.reset() after saving to prepare for next scan -->Dashboard pages use consistent section layouts.
Main section wrapper with icon, title, and description:
<script lang="ts">
import DashboardSection from '$lib/components/Dashboard/DashboardSection.svelte';
</script>
<DashboardSection
icon="users"
title="Team Members"
description="Manage your conference team"
variant="default"
>
<!-- Section content -->
</DashboardSection>Props:
icon: FontAwesome icon name (withoutfa-prefix)title: Section headingdescription: Optional subtitlevariant:"default"|"info"(info uses blue styling)collapsible: Boolean to make the section collapsibledefaultCollapsed: Boolean for initial collapsed statecollapsed: Bindable boolean to track/control collapsed state externallyheaderAction: Optional snippet for header actions
Simple card container for content:
<script lang="ts">
import DashboardContentCard from '$lib/components/Dashboard/DashboardContentCard.svelte';
</script>
<DashboardContentCard title="Statistics" description="Overview of current data">
<p>Card content here...</p>
</DashboardContentCard>Conference title header with emblem and status:
<ConferenceHeader
title={conference.title}
longTitle={conference.longTitle}
state={conference.state}
startDate={conference.startConference}
endDate={conference.endConference}
emblemDataURL={conference.emblemDataURL}
/>Checklist table with status icons:
<script lang="ts">
import TodoTable from '$lib/components/Dashboard/TodoTable.svelte';
</script>
<TodoTable
todos={[
{ title: 'Complete registration', completed: true },
{ title: 'Upload photo', completed: false, help: 'Required for badge' },
{ title: 'Pay fees', completed: undefined } // Shows loading spinner
]}
/>Survey components handle displaying and answering surveys on the participant dashboard.
Self-fetching dashboard component (like CalendarSection) that queries surveys and renders them. Renders nothing if no surveys exist.
File: src/lib/components/Dashboard/SurveySection.svelte
Props: conferenceId: string, userId: string, conferenceTimezone: string
Behavior:
- Fetches non-hidden, non-draft surveys via its own GraphQL query
- Wraps content in a collapsible
DashboardSection - Auto-collapses when all surveys are answered
- Shows pinned selection cards below the section when collapsed (for surveys with
showSelectionOnDashboard)
Compact card for a single survey within the dashboard section.
File: src/lib/components/Survey/SurveyCard.svelte
Props:
question:{ id, title, description, deadline, showSelectionOnDashboard, options: [...] }answer:{ option: { id, title } } | undefineduserId: stringconferenceTimezone: string
Shows deadline status, title, description, current answer badge, and an "Answer"/"Change answer" button that opens SurveyAnswerModal.
Modal for answering or changing a survey answer with radio option cards and capacity indicators.
File: src/lib/components/Survey/SurveyAnswerModal.svelte
Props:
open: boolean (bindable)question:{ id, title, description, deadline, options: [{ id, title, description, upperLimit, countSurveyAnswers }] }currentAnswerOptionId:string | undefineduserId: stringconferenceTimezone: string
Locks submission after deadline. Contains its own updateOneSurveyAnswer mutation with cache invalidation.
Reusable timezone-aware deadline display.
File: src/lib/components/DeadlineDisplay.svelte
Props: deadline: Date | string, conferenceTimezone: string
Shows an open/closed badge with the deadline formatted in the conference timezone. If the user's local timezone differs, shows their local time below.
Searchable, sortable table with optional row expansion:
<script lang="ts">
import DataTable from '$lib/components/DataTable/DataTable.svelte';
const columns = [
{ key: 'name', title: 'Name', value: (row) => row.name, sortable: true },
{ key: 'email', title: 'Email', value: (row) => row.email }
];
</script>
<DataTable
{columns}
rows={data}
enableSearch={true}
sortBy="name"
rowSelected={(row) => handleRowClick(row)}
/>With expandable rows:
<DataTable {columns} rows={data} showExpandIcon={true}>
{#snippet expandedRowContent(row)}
<div class="p-4">
<p>Expanded content for {row.name}</p>
</div>
{/snippet}
</DataTable>Statistics widgets using DaisyUI stats component:
<script lang="ts">
import GenericWidget from '$lib/components/DelegationStats/GenericWidget.svelte';
</script>
<GenericWidget
content={[
{ icon: 'users', title: 'Total Members', value: 42, desc: '+5 this week' },
{ icon: 'check-circle', title: 'Confirmed', value: 38 },
{ icon: 'clock', title: 'Pending', value: 4 }
]}
/>Key-value pair display:
<script lang="ts">
import Grid from '$lib/components/InfoGrid/Grid.svelte';
import Entry from '$lib/components/InfoGrid/Entry.svelte';
</script>
<Grid>
<Entry title="Name" fontAwesomeIcon="user" content="John Doe" />
<Entry title="Email" fontAwesomeIcon="envelope" content="john@example.com" />
<Entry title="Status" fontAwesomeIcon="circle-check">
<span class="badge badge-success">Active</span>
</Entry>
</Grid>Sidebar navigation:
<script lang="ts">
import NavMenu from '$lib/components/NavMenu/NavMenu.svelte';
import NavMenuButton from '$lib/components/NavMenu/NavMenuButton.svelte';
import NavMenuDetails from '$lib/components/NavMenu/NavMenuDetails.svelte';
let expanded = $state(true);
</script>
<NavMenu>
<NavMenuButton title="Dashboard" href="/dashboard" icon="fa-home" bind:expanded />
<NavMenuDetails title="Settings" icon="fa-cog">
<NavMenuButton title="General" href="/settings/general" icon="fa-gear" bind:expanded />
<NavMenuButton title="Security" href="/settings/security" icon="fa-shield" bind:expanded />
</NavMenuDetails>
</NavMenu>Tab navigation:
<script lang="ts">
import Tabs from '$lib/components/Tabs/Tabs.svelte';
import Tab from '$lib/components/Tabs/Tab.svelte';
let activeTab = $state('overview');
</script>
<Tabs>
<Tab
title="Overview"
icon="chart-pie"
active={activeTab === 'overview'}
onclick={() => (activeTab = 'overview')}
/>
<Tab
title="Members"
icon="users"
active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}
/>
</Tabs>
{#if activeTab === 'overview'}
<!-- Overview content -->
{:else if activeTab === 'members'}
<!-- Members content -->
{/if}Colored status indicator with optional blink:
<script lang="ts">
import StatusLight from '$lib/components/StatusLight.svelte';
</script>
<StatusLight color="success" blink={true} tooltip="Online" />
<StatusLight color="warning" blink={false} tooltip="Pending" />
<StatusLight color="error" blink={false} tooltip="Offline" />Colors: success, warning, error, info
Sizes: xs, sm, md, lg, xl
Use DaisyUI badges for status labels:
<span class="badge badge-success">Active</span>
<span class="badge badge-warning">Pending</span>
<span class="badge badge-error">Rejected</span>
<span class="badge badge-info">New</span>
<span class="badge badge-neutral">Archived</span>Use FontAwesome Duotone icons throughout the application:
<!-- Regular duotone icon -->
<i class="fa-duotone fa-user"></i>
<!-- Solid version for active states -->
<i class="fas fa-user"></i>
<!-- With size -->
<i class="fa-duotone fa-user text-2xl"></i>
<!-- With color -->
<i class="fa-duotone fa-check text-success"></i>
<i class="fa-duotone fa-times text-error"></i>Use DaisyUI semantic color classes:
bg-base-100- Primary backgroundbg-base-200- Secondary/muted backgroundbg-base-300- Tertiary/hover background
text-base-content- Primary texttext-base-content/60- Muted texttext-primary- Primary accenttext-secondary- Secondary accent
text-success/bg-success- Success/positivetext-warning/bg-warning- Warning/cautiontext-error/bg-error- Error/dangertext-info/bg-info- Information
border-base-200- Light borderborder-base-300- Medium border
For centered content pages:
<div class="flex w-full flex-col items-center">
<div class="w-full max-w-4xl">
<!-- Page content -->
</div>
</div>For dashboard pages with multiple sections:
<div class="flex w-full flex-col gap-10">
<ConferenceHeader ... />
<DashboardSection ...>
<!-- Section 1 content -->
</DashboardSection>
<DashboardSection ...>
<!-- Section 2 content -->
</DashboardSection>
</div>For card-based layouts:
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<div class="card bg-base-100 border border-base-200 shadow-sm">
<div class="card-body">
<!-- Card content -->
</div>
</div>
</div><!-- Spinner -->
<span class="loading loading-spinner loading-md"></span>
<!-- Skeleton -->
<div class="skeleton h-4 w-full"></div>
<!-- Loading dots -->
<span class="loading loading-dots loading-sm"></span><div class="flex flex-col items-center justify-center py-12 text-center">
<i class="fa-duotone fa-inbox text-4xl text-base-content/30 mb-4"></i>
<p class="text-base-content/60">No items found</p>
</div><!-- Primary action -->
<button class="btn btn-primary">Save</button>
<!-- Secondary action -->
<button class="btn btn-ghost">Cancel</button>
<!-- Danger action -->
<button class="btn btn-error">Delete</button>
<!-- Icon button -->
<button class="btn btn-square btn-ghost btn-sm">
<i class="fa-duotone fa-pencil"></i>
</button>Use sveltekit-search-params for URL-persisted state:
<script lang="ts">
import { queryParam } from 'sveltekit-search-params';
const tabParam = queryParam('tab');
let activeTab = $derived($tabParam ?? 'overview');
function setTab(tab: string) {
$tabParam = tab;
}
</script>Before implementing new UI:
- Check if a component already exists in
src/lib/components/ - Use
FormFieldsetfor all form groupings - Use DaisyUI classes before writing custom CSS
- Use semantic color classes (not hardcoded colors)
- Include loading and empty states
- Test responsive behavior (mobile-first)
- Add proper aria labels for accessibility
- Use the
$t()function from Paraglide-JS for all user-facing text (i18n)