diff --git a/CHANGELOG.md b/CHANGELOG.md
index c924330..7cdf5a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
- **Faster login redirects.** If you're not signed in and try to open a dashboard or settings page, you're redirected to login immediately instead of seeing a blank page first. Already-signed-in users who visit the login page are sent straight to the dashboard.
- **Graceful error recovery.** If a page crashes, you now see a friendly error screen with a "Try again" button instead of a blank white page. Each section of the app has its own error message so you know exactly what went wrong.
- **Security headers.** All pages now include clickjacking protection, MIME-sniffing prevention, a strict referrer policy, and HSTS. Browser APIs like camera and microphone are explicitly disabled.
+- **Better form experience.** Forms now auto-focus the first field when they open, text inputs enforce character limits with a visible counter when you're close, and the settings page warns you before navigating away with unsaved changes.
## [0.10.0-alpha] - 2026-02-21
diff --git a/app/sites/[id]/funnels/new/page.tsx b/app/sites/[id]/funnels/new/page.tsx
index 50da204..bc950a7 100644
--- a/app/sites/[id]/funnels/new/page.tsx
+++ b/app/sites/[id]/funnels/new/page.tsx
@@ -120,8 +120,13 @@ export default function CreateFunnelPage() {
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Signup Flow"
+ autoFocus
required
+ maxLength={255}
/>
+ {name.length > 200 && (
+ 240 ? 'text-amber-500' : 'text-neutral-400'}`}>{name.length}/255
+ )}
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx
index 2ff7514..8328ffb 100644
--- a/app/sites/[id]/settings/page.tsx
+++ b/app/sites/[id]/settings/page.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useEffect, useState } from 'react'
+import { useEffect, useState, useRef } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getSite, updateSite, resetSiteData, deleteSite, type Site, type GeoDataLevel } from '@/lib/api/sites'
import { listGoals, createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
@@ -13,6 +13,7 @@ import { PasswordInput } from '@ciphera-net/ui'
import { Select, Modal, Button } from '@ciphera-net/ui'
import { APP_URL } from '@/lib/api/client'
import { generatePrivacySnippet } from '@/lib/utils/privacySnippet'
+import { useUnsavedChanges } from '@/lib/hooks/useUnsavedChanges'
import { getSubscription, type SubscriptionDetails } from '@/lib/api/billing'
import { getRetentionOptionsForPlan, formatRetentionMonths } from '@/lib/plans'
import { motion, AnimatePresence } from 'framer-motion'
@@ -86,6 +87,7 @@ export default function SiteSettingsPage() {
const [editingGoal, setEditingGoal] = useState(null)
const [goalForm, setGoalForm] = useState({ name: '', event_name: '' })
const [goalSaving, setGoalSaving] = useState(false)
+ const initialFormRef = useRef('')
useEffect(() => {
loadSite()
@@ -146,6 +148,20 @@ export default function SiteSettingsPage() {
// Data retention (default 6 = free-tier max; avoids flash-then-clamp for existing sites)
data_retention_months: data.data_retention_months ?? 6
})
+ initialFormRef.current = JSON.stringify({
+ name: data.name,
+ timezone: data.timezone || 'UTC',
+ is_public: data.is_public || false,
+ excluded_paths: (data.excluded_paths || []).join('\n'),
+ collect_page_paths: data.collect_page_paths ?? true,
+ collect_referrers: data.collect_referrers ?? true,
+ collect_device_info: data.collect_device_info ?? true,
+ collect_geo_data: data.collect_geo_data || 'full',
+ collect_screen_resolution: data.collect_screen_resolution ?? true,
+ enable_performance_insights: data.enable_performance_insights ?? false,
+ filter_bots: data.filter_bots ?? true,
+ data_retention_months: data.data_retention_months ?? 6
+ })
if (data.has_password) {
setIsPasswordEnabled(true)
} else {
@@ -264,6 +280,20 @@ export default function SiteSettingsPage() {
data_retention_months: formData.data_retention_months
})
toast.success('Site updated successfully')
+ initialFormRef.current = JSON.stringify({
+ name: formData.name,
+ timezone: formData.timezone,
+ is_public: formData.is_public,
+ excluded_paths: formData.excluded_paths,
+ collect_page_paths: formData.collect_page_paths,
+ collect_referrers: formData.collect_referrers,
+ collect_device_info: formData.collect_device_info,
+ collect_geo_data: formData.collect_geo_data,
+ collect_screen_resolution: formData.collect_screen_resolution,
+ enable_performance_insights: formData.enable_performance_insights,
+ filter_bots: formData.filter_bots,
+ data_retention_months: formData.data_retention_months
+ })
loadSite()
} catch (error: any) {
toast.error(getAuthErrorMessage(error) || 'Failed to save site settings')
@@ -317,6 +347,23 @@ export default function SiteSettingsPage() {
setTimeout(() => setSnippetCopied(false), 2000)
}
+ const isFormDirty = initialFormRef.current !== '' && JSON.stringify({
+ name: formData.name,
+ timezone: formData.timezone,
+ is_public: formData.is_public,
+ excluded_paths: formData.excluded_paths,
+ collect_page_paths: formData.collect_page_paths,
+ collect_referrers: formData.collect_referrers,
+ collect_device_info: formData.collect_device_info,
+ collect_geo_data: formData.collect_geo_data,
+ collect_screen_resolution: formData.collect_screen_resolution,
+ enable_performance_insights: formData.enable_performance_insights,
+ filter_bots: formData.filter_bots,
+ data_retention_months: formData.data_retention_months
+ }) !== initialFormRef.current
+
+ useUnsavedChanges(isFormDirty)
+
useEffect(() => {
if (site?.domain) document.title = `Settings ยท ${site.domain} | Pulse`
}, [site?.domain])
@@ -454,11 +501,15 @@ export default function SiteSettingsPage() {
type="text"
id="name"
required
+ maxLength={255}
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white"
/>
+ {formData.name.length > 200 && (
+ 240 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/255
+ )}
@@ -1062,6 +1113,7 @@ export default function SiteSettingsPage() {
value={goalForm.name}
onChange={(e) => setGoalForm({ ...goalForm, name: e.target.value })}
placeholder="e.g. Signups"
+ autoFocus
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required
/>
@@ -1073,10 +1125,14 @@ export default function SiteSettingsPage() {
value={goalForm.event_name}
onChange={(e) => setGoalForm({ ...goalForm, event_name: e.target.value })}
placeholder="e.g. signup_click (letters, numbers, underscores only)"
+ maxLength={64}
className="w-full px-4 py-2 border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 text-neutral-900 dark:text-white"
required
/>
-
Only letters, numbers, and underscores; spaces become underscores. Invalid characters cannot be used. Max 64 characters after formatting.
+
+
Letters, numbers, and underscores only. Spaces become underscores.
+
56 ? 'text-amber-500' : 'text-neutral-400'}`}>{goalForm.event_name.length}/64
+
{editingGoal && goalForm.event_name.trim().toLowerCase().replace(/\s+/g, '_') !== editingGoal.event_name && (
Changing event name does not reassign events already tracked under the previous name.
)}
diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx
index b00a060..deb9884 100644
--- a/app/sites/[id]/uptime/page.tsx
+++ b/app/sites/[id]/uptime/page.tsx
@@ -937,8 +937,13 @@ function MonitorForm({
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. API, Website, CDN"
+ autoFocus
+ maxLength={255}
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-white placeholder-neutral-400 focus:outline-none focus:ring-2 focus:ring-brand-orange focus:border-transparent text-sm"
/>
+ {formData.name.length > 200 && (
+
240 ? 'text-amber-500' : 'text-neutral-400'}`}>{formData.name.length}/255
+ )}
{/* URL with protocol dropdown + domain prefix */}
diff --git a/app/sites/new/page.tsx b/app/sites/new/page.tsx
index 0dd1829..3e3ae09 100644
--- a/app/sites/new/page.tsx
+++ b/app/sites/new/page.tsx
@@ -191,6 +191,8 @@ export default function NewSitePage() {
setFormData({ ...formData, name: e.target.value })}
placeholder="My Website"
@@ -204,6 +206,7 @@ export default function NewSitePage() {
setFormData({ ...formData, domain: e.target.value.toLowerCase().trim() })}
placeholder="example.com"
diff --git a/lib/hooks/useUnsavedChanges.ts b/lib/hooks/useUnsavedChanges.ts
new file mode 100644
index 0000000..99016ba
--- /dev/null
+++ b/lib/hooks/useUnsavedChanges.ts
@@ -0,0 +1,24 @@
+'use client'
+
+import { useEffect, useCallback } from 'react'
+
+/**
+ * Warns users with a browser prompt when they try to navigate away
+ * or close the tab while there are unsaved form changes.
+ *
+ * @param isDirty - Whether the form has unsaved changes
+ */
+export function useUnsavedChanges(isDirty: boolean) {
+ const handleBeforeUnload = useCallback(
+ (e: BeforeUnloadEvent) => {
+ if (!isDirty) return
+ e.preventDefault()
+ },
+ [isDirty]
+ )
+
+ useEffect(() => {
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload)
+ }, [handleBeforeUnload])
+}