← Framework

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.

1Presentation LayerTiered form system with contenteditable fields, dynamic tables, and print layout
2Application StateActive brief tracking, tier switching, debounced auto-save, completeness tracking
3Storage AbstractionBriefStorage module with routed save/load, dual-index architecture
4Auth & SyncAuthState abstraction, SyncStatus state machine, migration flow
5PersistencelocalStorage (local-first), Supabase PostgreSQL (cloud sync), offline detection

Tech stack

TechnologyPurpose
Vanilla JavaScriptAll application logic: no framework, no build step
IIFE modulesBriefStorage, AuthState, and SyncStatus encapsulated as revealing module pattern
localStoragePer-brief payloads and lightweight metadata index
Web Crypto APICollision-resistant brief ID generation via crypto.getRandomValues
Custom EventsReactive UI updates via auth-changed, sync-status-changed
CSS prefers-color-schemeFull 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 printA4 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

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

MethodPurpose
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:

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.

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:

local
syncing
synced
StateLabelMeaning
localSaved locallyBrief exists only in the browser. Shown for all anonymous users and for signed-in users with unsynced changes
syncingSyncing…Push to database is in progress. Transitional state
syncedSynced to accountDatabase 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

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.

TierNameFieldsUse case
Tier 1Snapshot6 fieldsQuick sends, BAU campaigns. Five minutes to complete
Tier 2Journey11 sections across 5 layersMulti-touch journeys with behaviour design, entry/exit logic, and audience lifecycle
Tier 3Architecture11 sectionsEnterprise 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:

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

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:

FieldTypePurpose
idstringUnique identifier (timestamp + crypto random)
tiernumber1, 2, or 3
namestringCampaign name (derived from first field)
statusstringactive or archived
syncStatusstringlocal, syncing, or synced
createdISO stringCreation timestamp
updatedISO stringLast modification timestamp
fieldsobjectJSONB blob: the form knows its own structure, the storage layer passes it through
archivedAtISO 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.

Database schema

Briefs are stored in a single briefs table in Supabase PostgreSQL:

ColumnTypePurpose
idTEXT (PK)Client-generated brief ID (timestamp + crypto random)
user_idUUID (FK)References auth.users(id), enforced by RLS
tierINTEGER1, 2, or 3
nameTEXTCampaign name
statusTEXTactive or archived
fieldsJSONBOpaque form data blob: the storage layer passes it through unchanged
archived_atTIMESTAMPTZArchival timestamp (null for active briefs)
created_atTIMESTAMPTZCreation timestamp
updated_atTIMESTAMPTZLast 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:

FunctionSupabase 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:

  1. Detect all existing local briefs
  2. Set each brief’s sync status to syncing
  3. Push each brief to Supabase via remoteSave()
  4. On success, transition to synced
  5. 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:

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

PatternDetail
Hover revealsDelete and action buttons on brief list items are invisible until hover (opacity 0 → 1, 0.15s transition)
Focus ringsConsistent blue focus ring (box-shadow: 0 0 0 3px rgba(59,130,246,0.12)) across all interactive elements
Modal focus trapConfirmation modals trap focus and return it to the triggering element on dismiss
Debounced input400ms save delay with cursor position preservation to avoid disruptive jumps
Flash confirmationSave 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.