diff --git a/.impeccable.md b/.impeccable.md deleted file mode 100644 index d6257d4b0..000000000 --- a/.impeccable.md +++ /dev/null @@ -1,17 +0,0 @@ -## Design Context - -### Users -Developer-founders and indie hackers building SaaS products. They use Plunk to handle transactional and marketing email without the complexity of tools like Mailchimp or Customer.io. They notice tiny details — inconsistent spacing, placeholder text that adds no value, a button that doesn't communicate state. Context: professional environment, desktop-first. - -### Brand Personality -Sharp, minimal, confident. The product earns trust by being simple and correct, not by being flashy. Testimonials emphasize "transparent UI", "easy setup", "clean design" — the brand is *care without noise*. - -### Aesthetic Direction -Light mode only. Palette: black (`neutral-900`), neutral grays, white. No accent colors. No color for decoration — only for semantics (red = error, green = success). Backgrounds are near-white with subtle texture. Cards use white with a neutral border and light shadow. Typography should feel precise and legible, not editorial. Spacing should feel considered, not generous. - -### Design Principles -1. **Every pixel earns its place.** If something doesn't communicate information or provide affordance, remove it. -2. **Neutral by default, semantic by exception.** Color is reserved for error/success/warning states, not decoration. -3. **Interaction should feel fast.** Loading states communicate exactly what's happening. No silent actions. -4. **Developer-grade precision.** Copy is short and direct. Placeholders only appear when they add value. Labels are unambiguous. -5. **Consistency is trust.** The same pattern everywhere. One way to show errors. One way to show success. No creative variation in functional UI. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..aa969de1f --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,74 @@ +# Product + +## Register + +product + +## Users + +Developer-founders and indie hackers building SaaS products. They use Plunk to handle +transactional email, marketing campaigns, and workflow automation without the complexity of +Mailchimp or Customer.io. They are technically fluent: they read API docs, they know what a +webhook is, they do not need "email marketing" explained to them. + +They notice tiny details. Inconsistent spacing, placeholder text that adds no value, a button +that does not communicate state, the same concept called two different names on two screens. + +Context: professional environment, desktop-first, task-focused. They are usually mid-task +(shipping a signup flow, debugging a bounce, launching a campaign), not browsing. + +## Product Purpose + +Plunk is an open-source email platform that unifies transactional sending, marketing campaigns, +and automation. Self-hostable, $0.001 per email, no contact limits. Success looks like a +developer getting from signup to a first sent email without reading documentation, and never +needing a support ticket to understand what a screen is asking of them. + +## Brand Personality + +Sharp, minimal, confident. The product earns trust by being simple and correct, not by being +flashy. Testimonials emphasize "transparent UI", "easy setup", "clean design". The brand is +*care without noise*. + +Voice: direct, technical peer to technical peer. Short sentences. Says what a thing does, not +how great it is. Never markets to the user inside the app. + +## Anti-references + +- Mailchimp, Customer.io, HubSpot: feature-bloat, tutorial-tone copy, marketing language inside + the product UI. +- Enterprise SaaS onboarding that explains its own navigation in paragraphs. +- Copy that hedges ("you may want to consider..."), apologizes, or over-reassures. + +## Design Principles + +1. **Every pixel earns its place.** If something does not communicate information or provide an + affordance, remove it. +2. **Neutral by default, semantic by exception.** Color is reserved for error/success/warning + states, not decoration. +3. **Interaction should feel fast.** Loading states communicate exactly what is happening. No + silent actions. +4. **Developer-grade precision.** Copy is short and direct. Placeholders only appear when they + add value. Labels are unambiguous. +5. **Consistency is trust.** The same pattern everywhere. One way to show errors, one way to + show success. No creative variation in functional UI. + +## Terminology + +User-facing vocabulary is fixed and matches the API and documentation. Do not rename these in +the UI: Contact, Event, Action, Trigger, Campaign, Workflow, Template, Segment, Project, API key. +Standardize the wording *around* them instead (one verb for delete, one for create, and so on). + +## Aesthetic Direction + +Light mode only. Palette: black (`neutral-900`), neutral grays, white. No accent colors, no +color for decoration. Backgrounds are near-white with subtle texture; auth pages use a dot-grid +(`radial-gradient(#e5e7eb 1px, transparent 1px)` at `20px 20px`). Cards are white with a neutral +border and light shadow. Typography is precise and legible, not editorial. Spacing is +considered, not generous. + +## Accessibility & Inclusion + +Desktop-first but must not break on tablet. Link text carries standalone meaning, icon-only +buttons carry `aria-label`, error text is never conveyed by color alone. Respect +`prefers-reduced-motion`. diff --git a/apps/web/src/components/ActivityFeed.tsx b/apps/web/src/components/ActivityFeed.tsx index 6e018d939..413aff2cc 100644 --- a/apps/web/src/components/ActivityFeed.tsx +++ b/apps/web/src/components/ActivityFeed.tsx @@ -87,7 +87,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi setNextCursor(result.cursor); setHasMore(result.hasMore); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load activities'); + setError(err instanceof Error ? err.message : 'Couldn’t load activity. Try again.'); console.error('Error fetching activities:', err); } finally { setIsLoading(false); @@ -179,7 +179,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
{error}
diff --git a/apps/web/src/components/BillingConsumption.tsx b/apps/web/src/components/BillingConsumption.tsx
index ff3c5964e..f3b5e3247 100644
--- a/apps/web/src/components/BillingConsumption.tsx
+++ b/apps/web/src/components/BillingConsumption.tsx
@@ -9,6 +9,19 @@ interface BillingConsumptionProps {
hasSubscription: boolean;
}
+/** One header for every state, so the title is written once rather than four times. */
+function UsageCard({period, children}: {period?: string; children: React.ReactNode}) {
+ return (
+
+
+ Usage this month
+ {period && Billing period: {period} }
+
+ {children}
+
+ );
+}
+
export function BillingConsumption({projectId, hasSubscription}: BillingConsumptionProps) {
const {data: config} = useConfig();
const billingEnabled = config?.features.billing.enabled ?? false;
@@ -39,58 +52,37 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
if (!hasSubscription) {
return (
-
-
- Usage This Month
- View your current month email consumption and costs
-
-
-
-
-
-
- Usage tracking is only available with an active subscription. Start a subscription to track your email
- consumption.
-
-
-
-
-
+
+
+
+
+ Usage tracking starts when you start a subscription.
+
+
+
);
}
if (isLoading) {
return (
-
-
- Usage This Month
- View your current month email consumption and costs
-
-
-
-
-
-
-
+
+
+
+
+
);
}
if (error) {
return (
-
-
- Usage This Month
- View your current month email consumption and costs
-
-
-
-
-
- Failed to load consumption data. Please try again later.
-
-
-
-
+
+
+
+
+ Couldn’t load usage data. Try again in a moment.
+
+
+
);
}
@@ -99,14 +91,9 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
}
return (
-
-
- Usage This Month
-
- Billing period: {formatDate(consumptionData.period.start)} - {formatDate(consumptionData.period.end)}
-
-
-
+
{/* Total Usage */}
@@ -115,7 +102,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
- Total Emails Sent
+ Total emails sent
{consumptionData.usage.total.toLocaleString()}
@@ -129,7 +116,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
- Account Credits
+ Account credits
{formatCurrency(consumptionData.credits.creditAmount, consumptionData.credits.currency)}
@@ -144,10 +131,10 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
{/* Upcoming Invoice */}
{consumptionData.upcomingInvoice && (
- Upcoming Invoice
+ Upcoming invoice
- Billing Period
+ Billing period
{formatDate(consumptionData.upcomingInvoice.periodStart)} -{' '}
{formatDate(consumptionData.upcomingInvoice.periodEnd)}
@@ -161,7 +148,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
{consumptionData.credits && consumptionData.credits.hasCredits && (
- Credits Applied
+ Credits applied
-{formatCurrency(consumptionData.credits.creditAmount, consumptionData.credits.currency)}
@@ -169,7 +156,7 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
)}
- Amount Due
+ Amount due
{formatCurrency(
consumptionData.upcomingInvoice.amountDue,
@@ -192,7 +179,6 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
)}
-
-
+
);
}
diff --git a/apps/web/src/components/BillingInvoices.tsx b/apps/web/src/components/BillingInvoices.tsx
index b4f7ed5a4..25a4b93c0 100644
--- a/apps/web/src/components/BillingInvoices.tsx
+++ b/apps/web/src/components/BillingInvoices.tsx
@@ -1,4 +1,4 @@
-import {Alert, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, IconSpinner} from '@plunk/ui';
+import {Alert, Badge, Button, Card, CardContent, CardHeader, CardTitle, IconSpinner} from '@plunk/ui';
import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react';
import {useBillingInvoices} from '../lib/hooks/useBillingInvoices';
@@ -8,63 +8,54 @@ interface BillingInvoicesProps {
onManageBilling?: () => void;
}
+/** One header for every state, so the title is written once rather than four times. */
+function InvoicesCard({children}: {children: React.ReactNode}) {
+ return (
+
+
+ Invoices
+
+ {children}
+
+ );
+}
+
export function BillingInvoices({projectId, hasSubscription, onManageBilling}: BillingInvoicesProps) {
const {invoicesData, isLoading, error} = useBillingInvoices(projectId, hasSubscription);
if (!hasSubscription) {
return (
-
-
- Past Invoices
- View and download your billing history
-
-
-
-
-
-
- Invoice history is only available with an active subscription. Start a subscription to view your
- invoices.
-
-
-
-
-
+
+
+
+
+ Invoices appear here once you start a subscription.
+
+
+
);
}
if (isLoading) {
return (
-
-
- Past Invoices
- View and download your billing history
-
-
-
-
-
-
-
+
+
+
+
+
);
}
if (error) {
return (
-
-
- Past Invoices
- View and download your billing history
-
-
-
-
-
- Failed to load invoices. Please try again later.
-
-
-
-
+
+
+
+
+ Couldn’t load your invoices. Try again in a moment.
+
+
+
);
}
@@ -119,17 +110,13 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
const hasMoreInvoices = invoicesData.invoices.length > 5;
return (
-
-
- Recent Invoices
- View your latest invoices (showing {recentInvoices.length} most recent)
-
-
+
+ <>
{invoicesData.invoices.length === 0 ? (
- No invoices found. Invoices will appear here after your first billing period.
+ Your first invoice appears here at the end of this billing period.
) : (
@@ -160,7 +147,7 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
)}
{isPaid(invoice) ? (
- Amount Paid:{' '}
+ Amount paid:{' '}
{formatCurrency(invoice.amountPaid, invoice.currency)}
) : (
@@ -170,7 +157,7 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
{formatCurrency(invoice.total, invoice.currency)}
- Amount Due:{' '}
+ Amount due:{' '}
{formatCurrency(invoice.amountDue, invoice.currency)}
>
@@ -214,21 +201,20 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
- Showing 5 most recent invoices. For complete billing history,{' '}
+ Showing your 5 most recent invoices.{' '}
{onManageBilling ? (
<>
- visit the{' '}
- .
+ Open the customer portal
+ {' '}
+ for your full history.
>
) : (
- 'use the Manage Billing button above to access the Customer Portal.'
+ 'Use Manage billing above for your full history.'
)}
@@ -236,7 +222,7 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
)}
)}
-
-
+ >
+
);
}
diff --git a/apps/web/src/components/BillingLimits.tsx b/apps/web/src/components/BillingLimits.tsx
index 84dea496e..61030be2d 100644
--- a/apps/web/src/components/BillingLimits.tsx
+++ b/apps/web/src/components/BillingLimits.tsx
@@ -109,12 +109,12 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr
// Revalidate SWR cache
await mutate();
setIsEditing(false);
- setSuccessMessage('Billing limits updated successfully');
+ setSuccessMessage('Billing limits updated');
// Clear success message after 3 seconds
setTimeout(() => setSuccessMessage(null), 3000);
} catch (error) {
- setErrorMessage(error instanceof Error ? error.message : 'Failed to update billing limits');
+ setErrorMessage(error instanceof Error ? error.message : 'Couldn’t save your billing limits. Try again.');
}
};
@@ -143,7 +143,7 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr
return (
- Billing Limits
+ Billing limits
Set monthly limits for each email category
@@ -158,7 +158,7 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr
return (
- Billing Limits
+ Billing limits
{tier === 'paid'
? 'Set monthly limits for each email category. Limits reset on the 1st of each month.'
@@ -229,7 +229,7 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr
{canEditLimits && (
-
+
)}
@@ -249,7 +249,7 @@ export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsPr
: null;
return (
- Workflow Emails Limit
+ Workflow emails limit
- Campaign Emails Limit
+ Campaign emails limit
- Transactional Emails Limit
+ Transactional emails limit
- Inbound Emails Limit
+ Inbound emails limit
diff --git a/apps/web/src/components/CampaignSelectionDialog.tsx b/apps/web/src/components/CampaignSelectionDialog.tsx
index 94f37dcda..6d06a88e9 100644
--- a/apps/web/src/components/CampaignSelectionDialog.tsx
+++ b/apps/web/src/components/CampaignSelectionDialog.tsx
@@ -147,7 +147,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
- All Campaigns
+ All campaigns
Draft
Scheduled
Sending
@@ -271,7 +271,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
/>
{selectedCampaign?.subject && (
{selectedCampaign.subject}
@@ -286,7 +286,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
toggleField('body')} />
Full email content and design
@@ -299,7 +299,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
toggleField('from')} />
{selectedCampaign?.from && (
{selectedCampaign.from}
@@ -318,7 +318,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
/>
{selectedCampaign?.fromName && (
{selectedCampaign.fromName}
@@ -337,7 +337,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
/>
{selectedCampaign?.replyTo && (
{selectedCampaign.replyTo}
@@ -356,7 +356,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
/>
{selectedCampaign && (
{getAudienceLabel(selectedCampaign)}
@@ -372,7 +372,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
Back
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index 154e4cd8b..da87e38b2 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -56,10 +56,10 @@ const NAV_ACTIONS: Action[] = [
];
const CREATE_ACTIONS: Action[] = [
- {label: 'New Campaign', href: '/campaigns/create', icon: Plus, keywords: 'create broadcast', shortcut: ['N', 'C']},
- {label: 'New Template', href: '/templates/create', icon: Plus, keywords: 'create email design', shortcut: ['N', 'T']},
- {label: 'New Segment', href: '/segments/new', icon: Plus, keywords: 'create group filter', shortcut: ['N', 'S']},
- {label: 'New Workflow', href: '/workflows', icon: Plus, keywords: 'create automation trigger', shortcut: ['N', 'W']},
+ {label: 'New campaign', href: '/campaigns/create', icon: Plus, keywords: 'create broadcast', shortcut: ['N', 'C']},
+ {label: 'New template', href: '/templates/create', icon: Plus, keywords: 'create email design', shortcut: ['N', 'T']},
+ {label: 'New segment', href: '/segments/new', icon: Plus, keywords: 'create group filter', shortcut: ['N', 'S']},
+ {label: 'New workflow', href: '/workflows', icon: Plus, keywords: 'create automation trigger', shortcut: ['N', 'W']},
];
// Chord map: first-key → second-key → action
diff --git a/apps/web/src/components/ContactPicker.tsx b/apps/web/src/components/ContactPicker.tsx
index d3037bf45..fbafca8c8 100644
--- a/apps/web/src/components/ContactPicker.tsx
+++ b/apps/web/src/components/ContactPicker.tsx
@@ -38,7 +38,7 @@ export function ContactPicker({
onChange,
onAdd,
existing = [],
- placeholder = 'Search contacts...',
+ placeholder = 'Search contacts…',
}: ContactPickerProps) {
const [mode, setMode] = useState('search');
@@ -154,7 +154,7 @@ export function ContactPicker({
setSearch(e.target.value)}
className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"
diff --git a/apps/web/src/components/DataManagementSettings.tsx b/apps/web/src/components/DataManagementSettings.tsx
index 0da3fb3a9..6bf4754fb 100644
--- a/apps/web/src/components/DataManagementSettings.tsx
+++ b/apps/web/src/components/DataManagementSettings.tsx
@@ -101,7 +101,7 @@ export function DataManagementSettings() {
setSelectedField(null);
mutateFields();
} catch (error) {
- toast.error(error instanceof Error ? error.message : 'Failed to delete field');
+ toast.error(error instanceof Error ? error.message : 'Couldn’t delete the field. Try again.');
} finally {
setIsDeleting(false);
}
@@ -122,7 +122,7 @@ export function DataManagementSettings() {
setSelectedEvent(null);
mutateEvents();
} catch (error) {
- toast.error(error instanceof Error ? error.message : 'Failed to delete event');
+ toast.error(error instanceof Error ? error.message : 'Couldn’t delete the event. Try again.');
} finally {
setIsDeleting(false);
}
@@ -145,10 +145,9 @@ export function DataManagementSettings() {
{/* Custom Contact Fields */}
- Custom Contact Fields
+ Custom contact fields
- Manage custom fields stored in your contact data. You can only delete fields that are not used in any
- segments or campaigns.
+ Fields set on contacts through the API. A field used by a segment or campaign can’t be deleted.
@@ -162,7 +161,7 @@ export function DataManagementSettings() {
- Field Name
+ Field name
Type
Coverage
Actions
@@ -194,10 +193,10 @@ export function DataManagementSettings() {
{/* Custom Events */}
- Custom Events
+ Custom events
- Manage custom events tracked in your project. You can only delete events that are not used in any segments
- or workflows. System events (email.*, segment.*) cannot be deleted.
+ Events your contacts have triggered. An event used by a segment or workflow can’t be deleted, and
+ neither can system events (email.*, segment.*).
@@ -211,7 +210,7 @@ export function DataManagementSettings() {
- Event Name
+ Event name
Actions
@@ -236,11 +235,13 @@ export function DataManagementSettings() {