CRM Brief BuilderTechnical Reference
This page covers the software architecture and product design behind the CRM Brief Builder, a local-first web application for creating structured CRM campaign briefs across three tiers of strategic depth. The tool is built as a single-file HTML application using vanilla JS, a routed storage abstraction, and a sync state machine backed by Supabase for authentication and cloud persistence. Users can start immediately without an account; signing in via magic link upgrades their briefs to cloud-synced storage. For the product thinking and the briefing methodology itself, start with the framework.
System Overview
The application is a self-contained, single-file HTML document. All markup, styles, and logic live in one file with no build step and no framework. The only external runtime dependency is the Supabase JS client (loaded via CDN) for authentication and cloud storage. The architecture follows modern local-first principles: the browser is the primary data store, the UI responds instantly, and the database layer acts as a sync engine rather than a gatekeeper.
Tech stack
| Technology | Purpose |
|---|---|
| Vanilla JavaScript | All application logic: no framework, no build step |
| IIFE modules | BriefStorage, AuthState, and SyncStatus encapsulated as revealing module pattern |
| localStorage | Per-brief payloads and lightweight metadata index |
| Web Crypto API | Collision-resistant brief ID generation via crypto.getRandomValues |
| Custom Events | Reactive UI updates via auth-changed, sync-status-changed |
CSS prefers-color-scheme | Full dark mode with 40+ overrides |
| Supabase (JS client v2) | Magic link authentication and cloud-synced brief storage via PostgreSQL with Row Level Security |
CSS @media print | A4 print layout with chrome removal and exact colour reproduction |
| Google Fonts (Inter) | Typography |
Local-First Architecture
The Brief Builder follows the same architectural philosophy as tools like Linear and Notion: the browser holds the working data, the UI responds instantly, and the database acts as a sync engine rather than a gatekeeper. Users can start writing a brief immediately with no sign-up, no loading spinner, and no server round-trip.
Why local-first
- Zero-friction entry: no account required to start. Open the page, pick a tier, begin writing
- Instant responsiveness: every keystroke saves to localStorage with a 400ms debounce. No network latency in the save path
- Offline by default: the tool works identically whether the user is online or not. Connectivity is additive, not required
- Trust through behaviour: the user sees “Saved locally” at all times. Their work is never in an ambiguous state
The product stance
This architecture is deliberate. The tool is designed to feel like a thinking instrument, not a gated platform. Account-based persistence is an upgrade, not a prerequisite. Users can sign in via magic link to sync their briefs to the cloud, but the tool works fully without an account. The local-first model ensures the tool remains immediate, safe to use, and easy to trust, the same qualities the briefing framework itself is built around.
Storage Abstraction
All data access flows through a single BriefStorage module. The UI never touches localStorage directly. This decouples the entire form system from its persistence layer, so the same calling code works whether the user is anonymous (local-only) or signed in (local + Supabase).
BriefStorage interface
| Method | Purpose |
|---|---|
save(brief) | Persists full brief payload and updates the lightweight index |
load(id) | Returns full brief object from per-brief storage key |
list(filterStatus) | Returns index entries only: no full payload reads for the sidebar |
remove(id) | Deletes the brief payload and removes it from the index |
getMetadata(id) | Returns a single index entry (lightweight lookup) |
Dual-index architecture
Storage is split into two concerns:
- Index: a single
gerson-crm-briefs-indexkey holds a lightweight array of every brief’s metadata (id, name, tier, status, sync status, last updated). The sidebar reads only from this index; it never loads full brief payloads - Per-brief payloads: each brief’s full content is stored at
gerson-crm-brief-{id}. These are loaded on demand when the user opens a specific brief
This separation means the sidebar renders instantly regardless of how many briefs exist or how large their content is. The index is always local for fast rendering. When a user signs in, remote briefs are fetched from Supabase and merged into the local index so the sidebar stays complete.
Routed save/load
BriefStorage includes routed methods (routedSave, routedLoad, routedList, routedRemove) that check AuthState.isSignedIn() and connectivity before deciding whether to push to remote. When signed out, everything stays local. When signed in and online, saves are attempted against the remote layer. The routing is transparent to the rest of the application.
Storage meter
A visual meter at the bottom of the sidebar shows the user exactly how much localStorage capacity they are using. It iterates all keys, calculates byte usage, and displays the result against a conservative 5 MB estimate.
- 0–75%: blue progress bar (normal)
- 75–90%: amber warning state
- >90%: red critical state
This makes local storage tangible. Users understand their data lives in the browser, and the meter creates a natural path toward account-based storage: “Running out of space? Sign in to sync to the cloud.”
Sync State Machine
Every brief carries a sync status that tells the user exactly where their work lives and what holds authority. The SyncStatus module tracks per-brief state through three transitions:
| State | Label | Meaning |
|---|---|---|
local | Saved locally | Brief exists only in the browser. Shown for all anonymous users and for signed-in users with unsynced changes |
syncing | Syncing… | Push to database is in progress. Transitional state |
synced | Synced to account | Database copy is authoritative. Confirmed persistence |
Event-driven updates
State transitions fire a sync-status-changed custom event. The sidebar listens for this event and re-renders sync labels without polling or timers. The same pattern applies to auth-changed: when AuthState.setUser() is called, the entire UI reacts through event propagation.
Authority model
- If a brief is synced, the database copy is authoritative
- If a brief has local-only changes (pending sync, offline, or anonymous), the local copy holds authority
- The sync label always reflects which copy currently holds truth
Tiered Form System
The brief builder offers three tiers of strategic depth, matching the framework’s model. Each tier is a distinct form layout with its own fields, dynamic tables, and complexity level.
| Tier | Name | Fields | Use case |
|---|---|---|---|
| Tier 1 | Snapshot | 6 fields | Quick sends, BAU campaigns. Five minutes to complete |
| Tier 2 | Journey | 11 sections across 5 layers | Multi-touch journeys with behaviour design, entry/exit logic, and audience lifecycle |
| Tier 3 | Architecture | 11 sections | Enterprise CRM programmes with segmentation, stakeholder ownership, delivery phases, and data architecture |
Content-editable fields
Form inputs use contenteditable divs rather than <textarea> or <input> elements. This gives rich-text flexibility, natural multi-line behaviour, and eliminates the fixed-height limitations of standard form elements. Auto-save captures the inner HTML of each field and restores it on load, including cursor position preservation during debounced saves.
Dynamic tables
Tier 2 and Tier 3 forms include dynamic tables (stakeholder grids, delivery phases, KPI matrices) that start with three rows and allow the user to add or remove rows. Table data is serialised as part of the brief’s fields blob; the storage layer passes it through without caring about the structure inside.
Tier switching
Tab buttons switch between tiers. Each switch saves the current brief (if dirty), resets dynamic tables to the default row count, and loads the selected tier’s form layout. Briefs are tier-specific: a Tier 1 brief and a Tier 3 brief are separate documents with separate IDs.
Brief Lifecycle
A brief moves through a defined lifecycle from creation to archival. Every transition is visible to the user and handled through the storage abstraction.
Creation
New briefs receive an ID immediately via crypto.getRandomValues combined with a base-36 timestamp prefix. The format ({timestamp}-{random}) is sortable by creation time, collision-resistant, and stable across local-to-synced transitions. No server round-trip is needed to generate an identity.
Auto-save
Every field input triggers a 400ms debounced save. The debounce timer resets on each keystroke, so the save fires only after the user pauses typing. Cursor position is preserved across saves by saving and restoring the browser’s text selection range. A timestamp display shows “Saved just now”, then updates to relative time (“Saved 23s ago”), and eventually an absolute time (“Saved 14:32”). The timer refreshes every 30 seconds.
Soft delete and the Bin
Deletion is not destructive. When a user deletes a brief, it moves to a collapsible Bin section in the sidebar:
- The brief’s status changes to
archivedwith anarchivedAttimestamp - It disappears from the active brief list
- It appears in the Bin, where it can be restored or permanently deleted
- Modal confirmation dialogs adapt their language: “Move to bin?” for archive, “Permanently delete? This cannot be undone.” for hard delete
This gives users the confidence to clean up their workspace without fear of data loss. The entire Bin feature works in local storage alone; no accounts or database required.
Export and import
Briefs can be exported as JSON files (named after the brief title) and re-imported into any browser. Imported briefs receive a new ID on creation, preserving the original content and metadata. A “Copy as text” feature builds a Markdown representation of the brief for pasting into documents or AI tools.
Offline Resilience
The application detects connectivity changes in real time using the browser’s online and offline events.
Offline behaviour
- An amber banner slides into view when connectivity is lost, and disappears when it returns
- The active brief remains fully editable; auto-save continues to write to localStorage
- For signed-in users, switching to a different brief is blocked while offline (the database is unreachable). The UI communicates this clearly
- When connectivity returns, the active brief’s sync status transitions from “Saved locally” back to “Synced to account” after a successful push
Why this matters
A user writing a campaign brief on a train, in a meeting room with unreliable Wi-Fi, or on a mobile hotspot should never lose their work or see an error state. The offline model was designed before the backend was connected. Resilience is a first-class concern, not an afterthought.
Data Model
Brief envelope
Every brief is a minimal envelope wrapping a flexible content blob:
| Field | Type | Purpose |
|---|---|---|
id | string | Unique identifier (timestamp + crypto random) |
tier | number | 1, 2, or 3 |
name | string | Campaign name (derived from first field) |
status | string | active or archived |
syncStatus | string | local, syncing, or synced |
created | ISO string | Creation timestamp |
updated | ISO string | Last modification timestamp |
fields | object | JSONB blob: the form knows its own structure, the storage layer passes it through |
archivedAt | ISO string? | Archival timestamp (present only for deleted briefs) |
Fields as opaque blob
The fields object is intentionally opaque to the storage layer. Each tier defines its own field keys (t1-campaign-name, t2-outcome, t3-segmentation, etc.) and the form serialises them on save. The storage abstraction does not validate or transform the contents; it passes the blob through unchanged. This means tiers can evolve independently without storage migrations.
Index entry (lightweight)
The sidebar index stores only what it needs to render the brief list:
{ "id": "1h8xk5p-a3b2c1d4ef",
"name": "Q2 Onboarding Refresh",
"tier": 2,
"status": "active",
"updated": "2026-03-21T10:42:00Z",
"syncStatus": "local" }
Full brief payloads are never loaded for the sidebar. This keeps rendering instant regardless of brief count or content size.
Supabase Integration
The application uses Supabase for authentication and cloud-synced brief storage. The Supabase JS client (v2, loaded via CDN) connects to a hosted PostgreSQL database with Row Level Security. Users who never sign in use the tool entirely via localStorage; signing in upgrades their briefs to cloud persistence without any change to the UI or form logic.
Authentication
Authentication uses Supabase magic link (email OTP). A “Sign in / Sign up” button in the brand bar opens a modal where the user enters their email address. Supabase sends a one-time link; clicking it either signs in an existing account or creates a new one automatically. No passwords are stored or managed.
- Session persistence:
supabase.auth.getSession()restores the session on page load.onAuthStateChangereacts to sign-in, sign-out, and token refresh events - AuthState module: wraps authentication behind
isSignedIn(),getUser(),setUser(u), andclear(). WhensetUser()is called on session change, it triggers the migration flow, updates the brand bar UI, and firesauth-changed, and everything else reacts automatically - Sign-out: clicking the email address in the brand bar prompts confirmation, then calls
supabase.auth.signOut()
Database schema
Briefs are stored in a single briefs table in Supabase PostgreSQL:
| Column | Type | Purpose |
|---|---|---|
id | TEXT (PK) | Client-generated brief ID (timestamp + crypto random) |
user_id | UUID (FK) | References auth.users(id), enforced by RLS |
tier | INTEGER | 1, 2, or 3 |
name | TEXT | Campaign name |
status | TEXT | active or archived |
fields | JSONB | Opaque form data blob: the storage layer passes it through unchanged |
archived_at | TIMESTAMPTZ | Archival timestamp (null for active briefs) |
created_at | TIMESTAMPTZ | Creation timestamp |
updated_at | TIMESTAMPTZ | Last modification timestamp |
Row Level Security is enabled with per-operation policies: users can only SELECT, INSERT, UPDATE, and DELETE their own rows (where auth.uid() = user_id). An index on (user_id, status) accelerates sidebar queries.
Remote storage layer
Four functions handle all Supabase communication:
| Function | Supabase call |
|---|---|
remoteSave(brief) | supabase.from('briefs').upsert(row) |
remoteLoad(id) | supabase.from('briefs').select('*').eq('id', id).single() |
remoteList(filterStatus) | supabase.from('briefs').select('id,name,tier,status,updated_at') |
remoteRemove(id) | supabase.from('briefs').delete().eq('id', id) |
These are called by the BriefStorage routed methods. When signed in and online, saves go to both localStorage and Supabase. Loads are local-first with a remote fetch for signed-in users (caching the result locally). When offline, everything falls back to localStorage silently.
Migration flow
When a user signs in for the first time, migrateLocalToRemote() fires automatically:
- Detect all existing local briefs
- Set each brief’s sync status to
syncing - Push each brief to Supabase via
remoteSave() - On success, transition to
synced - Run
pruneLocalStorage()to remove all full payloads except the active brief
After migration, the database is the source of truth. Local storage holds only the single active brief as a working buffer. Switching briefs fetches from the database and caches locally. The user sees the same sidebar list, but the data now lives in the cloud rather than the browser.
On sign-in, the sidebar also pulls the remote brief index via remoteList() and merges any briefs not already in the local index, ensuring briefs created on other devices appear immediately.
Single-brief pruning
After migration completes, pruneLocalStorage() removes all full brief payloads from localStorage except the one currently open. The lightweight index entries remain (for fast sidebar rendering). This enforces the single-brief buffer model for signed-in users, so local storage never accumulates, and the storage meter stays low.
Design Patterns
Dark mode
Full dark mode support via prefers-color-scheme: dark with 40+ CSS overrides covering form fields, tables, modals, the sidebar, and the storage meter. Backgrounds shift to subtle transparency layers (rgba(255,255,255,0.04)) rather than flat dark colours, preserving depth and hierarchy.
Print layout
A dedicated print stylesheet transforms the brief into a clean A4 document:
- All interactive chrome (toolbar, sidebar, tier tabs, storage meter) is hidden
- Instructional text, hints, and empty-state messaging is removed
- A dark blue header banner is injected via CSS
::beforewith the brief title and tier label - Exact colour reproduction is enforced via
print-color-adjust: exact - Page sizing targets A4 portrait with controlled margins
Responsive layout
The sidebar collapses on mobile with the same TOC panel pattern used across the portfolio site. On desktop, the aside is fixed with the storage meter pinned to the bottom. Field layouts and dynamic tables adapt to viewport width.
Interaction patterns
| Pattern | Detail |
|---|---|
| Hover reveals | Delete and action buttons on brief list items are invisible until hover (opacity 0 → 1, 0.15s transition) |
| Focus rings | Consistent blue focus ring (box-shadow: 0 0 0 3px rgba(59,130,246,0.12)) across all interactive elements |
| Modal focus trap | Confirmation modals trap focus and return it to the triggering element on dismiss |
| Debounced input | 400ms save delay with cursor position preservation to avoid disruptive jumps |
| Flash confirmation | Save indicator flashes on successful save, then fades to relative timestamp |
No framework, by design
The application uses vanilla JavaScript with the revealing module pattern (IIFEs) for encapsulation. There is no React, no Vue, no build step. This is a deliberate choice: the tool is distributed as a single HTML file that can be opened from a file system, emailed, or hosted on any static server. The constraint drives simplicity, and simplicity is one of the product’s design principles.