Proclivity is a personal Manifest V3 Chrome extension that replaces the new-tab
page with a planning surface. Five tabs organize the work: Today (daily
task list), Sprint (time-boxed sprint with per-sprint task scopes and an
archived-sprint history), Long-term (open-ended goals and initiatives),
Gantt (one or more named Gantt charts with nested tasks, collapse/expand,
drag-to-move, and drag-to-resize), and Reminders (one-shot or
daily/weekly recurring notifications backed by chrome.alarms). Behind
everything sits an animated wireframe-mesh background whose color cycles
smoothly through the day — indigo at 1 am, bright blue by morning,
turquoise at noon, yellow in the afternoon, orange at sunset, red in the
evening, purple at 10 pm.
Local-only. Not published to the Chrome Web Store.
Momentum is beautiful but shallow. Notion is powerful but heavyweight and requires a context-switch. The goal here is a planning tool that loads the instant a new tab opens, persists everything locally without a server, and can be shaped exactly to how this person actually plans work — sprints, Gantt charts, and timed reminders — without paying for or waiting on anyone else's roadmap.
- Vite 5 + React 18 + TypeScript 5 (strict mode)
@crxjs/vite-plugin^2.0.0-beta.28 — wraps the MV3 manifest, handles content-script injection and hot-reload during devchrome.storage.local— single-key persistence (~10 MB cap)chrome.alarms+chrome.notifications— service-worker-driven reminder delivery@react-three/fiber^8 +three^0.169 — animated background, lazy-loaded so three.js stays out of the initial chunk
Today and Long-term are thin wrappers around the shared
TodoList component; the only difference is the
scope value ("today" or "long") stamped on each Todo record.
Sprint (src/sections/sprint/SprintManager.tsx)
manages a list of named sprints, each with startsAt/endsAt timestamps.
Exactly one sprint is "active" at a time (activeSprintId in
ProclivityState). Sprint tasks are Todo records with scope: "sprint" and
a sprintId foreign key. Sprints whose endsAt is in the past are shown as
collapsed, expandable rows in an "Archived" section; they are never deleted
automatically. The sprint header shows a progress bar driven by task-completion
ratio, plus a "Day N of M" calendar progress indicator.
Gantt (src/sections/Gantt.tsx + src/sections/gantt/ChartView.tsx)
supports multiple named charts, each a tab at the top of the section. Within a
chart, tasks have startsAt/endsAt timestamps and an optional parentId for
nesting. flattenTasks() in ganttUtils.ts
does a depth-first traversal and respects each task's collapsed flag, so
entire subtrees can be hidden. The timeline grid is rendered as an absolutely-
positioned canvas of divs; each bar responds to onPointerDown/onPointerMove/
onPointerUp via setPointerCapture so drag works across the bar boundary.
The 6-pixel edge zones on each bar switch to resize-start or resize-end mode;
the center zone is a move. Pressing Escape during a drag reverts the preview and
releases pointer capture. The drag preview is kept in local component state; the
final position is committed to storage only on pointer-up.
Reminders (src/sections/reminders/RemindersManager.tsx)
lets you create a reminder with a title, an exact fire time, a recurrence
(daily/weekly/none), and an optional link to an existing todo. When
a reminder is saved, the service worker's chrome.storage.onChanged listener
picks up the diff and creates a chrome.alarms entry. On alarm fire, a
chrome.notifications notification is shown. Recurring reminders have their
fireAt advanced by one day or one week (anchored from the original fireAt,
not the actual firing time, so they don't drift). Non-recurring reminders are
marked fired: true.
All state lives under a single key — "proclivity:state:v1" — in
chrome.storage.local. The entire ProclivityState object is read, patched,
and written back on every mutation. This keeps the storage model simple (no
partial-key merges, no migrations between fields) at the cost of one full read-
modify-write per operation.
Concurrent writes from the React UI would clobber each other without
serialization. storage.update() solves this with an
in-memory promise chain (writeChain): each call appends to the tail of the
chain, so writes execute in arrival order and each write reads the result of the
previous one.
The service worker runs in a separate module scope and cannot share the UI's
writeChain. It maintains a parallel queue (swWriteChain in
service-worker.ts) for the same reason.
Both queues enforce the same read-latest → transform → write-back sequence.
useStore() is a plain React hook, not a Context
provider. Every section that needs state calls it directly. This is a deliberate
choice: the app has five sections kept permanently mounted (all rendered, only
hidden with hidden={...}), so a shared context would re-render all five on
every state change anyway. With the hook-per-section pattern, each section's
re-render is triggered only when its own useStore() subscription fires — which
happens to be the same event since there is only one storage key. The hook
exposes state, loading, and a stable update callback (memoized via
useCallback so consumer useCallback deps stay clean).
MeshBackground is a full-screen WebGL
canvas rendered by @react-three/fiber. It is lazy-loaded via React.lazy in
App.tsx so the ~800 kB three.js bundle never blocks the
initial render.
The geometry is a PlaneGeometry (90 × 50 units, 140 × 80 segments) tilted to
a shallow perspective angle. A ShaderMaterial runs a 3-octave fractional
Brownian motion (fBm) in GLSL using the Ashima Arts simplex noise implementation.
Two pairs of slow sine waves drive a uWander uniform that offsets the noise
field coordinates each frame, so the warping appears to travel across the screen
rather than pulse in place. The fragment shader brightens peaks slightly based on
the vElevation varying. Color is updated each frame by interpolating between
eight time-of-day key colors using a smoothstep curve. Rendering is paused when
the tab is hidden (visibilitychange) and reduced to a single static frame when
prefers-reduced-motion: reduce is set.
Modal is a portal-rendered dialog with focus
trap, Escape-to-close, and save/restore of previously focused element. Two
specializations are exported alongside it: TextInputModal (for create/rename
flows) and ConfirmDialog (for destructive confirmations). The base Modal
uses useId() per instance so nested modals each have a unique
aria-labelledby target.
TodoItem is a shared <li> row used by
TodoList, SprintManager, and archived sprint rows.
On onInstalled and onStartup, the service worker calls reconcileAlarms().
This reads the current reminder list, computes which alarms should exist (all
non-fired reminders with a future fireAt), clears any orphaned
proclivity:reminder:* alarms, and creates any missing ones. Reminders whose
fireAt is already in the past at reconcile time are fired immediately (missed-
reminder path) and either advanced (if recurring) or marked fired.
The chrome.storage.onChanged listener runs diffAndSyncAlarms() on every
state change, comparing old and new reminder arrays to create, update, or clear
alarms reactively. This means the service worker stays synchronized without
needing explicit messages from the UI.
proclivity/
├── manifest.config.ts # MV3 manifest generated by @crxjs
├── vite.config.ts # Vite config: crx plugin, @ alias, build target
├── tsconfig.json # Strict TS: exactOptionalPropertyTypes, noUncheckedIndexedAccess
├── package.json
├── public/
│ ├── icon-16.png
│ ├── icon-48.png
│ └── icon-128.png
└── src/
├── newtab/
│ ├── index.html # Extension new-tab entry point
│ ├── App.tsx # Tab router, lazy MeshBackground, memoized Header
│ ├── App.css # Global layout, tab bar, header
│ └── index.css # CSS reset and root vars
├── background/
│ └── service-worker.ts # chrome.alarms + chrome.notifications + reconcileAlarms
├── types/
│ └── index.ts # Canonical data model: Todo, Sprint, GanttTask, GanttChart, Reminder, ProclivityState
├── storage/
│ ├── constants.ts # STORAGE_KEY = "proclivity:state:v1"
│ ├── storage.ts # get/set/update (write queue) + subscribe + uid()
│ └── useStore.ts # React hook: state + loading + stable update callback
├── sections/
│ ├── sections.css # Shared section layout styles
│ ├── Today.tsx # TodoList wrapper (scope="today")
│ ├── LongTerm.tsx # TodoList wrapper (scope="long")
│ ├── TodoList.tsx # Reusable add/toggle/delete list for Today and Long-term
│ ├── Sprint.tsx # Re-export of SprintManager
│ ├── Gantt.tsx # Chart-tab manager + modals; delegates to ChartView
│ ├── Reminders.tsx # Re-export of RemindersManager
│ ├── sprint/
│ │ ├── SprintManager.tsx # Full sprint UI: switcher, active sprint, archived sprints
│ │ ├── sprintUtils.ts # Date helpers: todayMidnight, isArchived, sprintDayProgress
│ │ └── sprint.css
│ ├── gantt/
│ │ ├── ChartView.tsx # Timeline grid, drag engine, add-task form
│ │ ├── TaskRow.tsx # Left-panel task row with collapse toggle
│ │ ├── ganttUtils.ts # flattenTasks, chartBounds, monthSpans, collectDescendants
│ │ └── gantt.css
│ └── reminders/
│ ├── RemindersManager.tsx # Add form, upcoming/fired lists
│ ├── reminderUtils.ts # relativeTime, tsToDatetimeLocal, formatFireAt
│ └── reminders.css
└── components/
├── Modal.tsx # Base Modal + TextInputModal + ConfirmDialog
├── Modal.css
├── TodoItem.tsx # Shared checkbox + title + delete row
├── MeshBackground.tsx # three.js GLSL warp mesh, time-of-day color cycle
└── MeshBackground.css
npm install
# Dev server with HMR (port 5173, HMR on 5174)
npm run dev
# Production build (tsc -b + vite build)
npm run build
# Type-check only, no emit
npm run typecheckLoad as an unpacked extension:
- Open
chrome://extensions - Enable Developer mode (toggle, top right)
- Click Load unpacked and select the
dist/directory (afternpm run build) or the@crxjsdev output directory duringnpm run dev - Open a new tab
During development, @crxjs/vite-plugin patches the extension manifest and
injects HMR plumbing automatically; you do not need to reload the extension
manually after most edits.
TypeScript strictness. The tsconfig.json enables strict: true plus two
additional flags:
exactOptionalPropertyTypes: true—foo?: Tandfoo?: T | undefinedare distinct; the code uses the latter whereundefinedmust be assignable in spreads.noUncheckedIndexedAccess: true— array index access returnsT | undefined; all callers must handle the undefined case or use a non-null assertion.
Both flags are non-negotiable. npm run build runs the full compiler suite and
must pass cleanly before any change is considered done.
The Photos tab plays a slideshow of photos the user picks from their Google account via the Photos Picker API. The feature is inert until you do the one-time Google Cloud setup below — the extension ships with a placeholder OAuth client_id that won't authenticate.
-
Pin the extension ID. OAuth client_ids are bound to a specific extension ID, so you need a stable one. After loading the unpacked extension once, copy its ID from
chrome://extensions. (For a long-lived install, add a"key"field to the manifest with the public key Chrome generated for that ID — see Chrome's docs on keeping an extension ID consistent.) -
Create a Google Cloud project. In the Google Cloud console, create a new project (or reuse one).
-
Enable the Photos Picker API. APIs & Services → Library → search for "Photos Picker API" → Enable.
-
Configure the OAuth consent screen.
- User type: External (with yourself added as a test user) is fine for a personal install; you do not need to publish the app.
- Add the scope
https://www.googleapis.com/auth/photospicker.mediaitems.readonly.
-
Create an OAuth client_id. APIs & Services → Credentials → Create credentials → OAuth client ID. Application type: Chrome extension. Paste your pinned extension ID into the Application ID field.
-
Drop the client_id into the manifest. Open
manifest.config.tsand replaceREPLACE_WITH_YOUR_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.comwith the client_id from step 5. Runnpm run buildand reload the unpacked extension. -
Connect from Settings. Open Settings → Google Photos → Connect Google Photos. Chrome will surface the consent dialog the first time; subsequent picks reuse the cached token.
How the photo data is handled. When you pick photos, the extension fetches
each one downscaled to 1600×1000 with Authorization: Bearer <token>,
encodes it as a base64 data URL, and writes the set under a dedicated
chrome.storage.local key (proclivity:photos:v1). The total cache is
budgeted at 6 MB so the main app data has headroom under Chrome's 10 MB
quota. The cached photos are not included in the JSON export — they live
outside ProclivityState by design.
The baseUrl on a Picker mediaItem expires roughly an hour after the pick
session, which is why we cache the bytes locally rather than storing the
URLs. Re-picking from the settings pane replaces the cache.
// src/types/index.ts
// Backs the Today, Sprint, and Long-term lists. scope + sprintId
// together determine which list a todo belongs to.
interface Todo {
id: string;
title: string;
notes?: string | undefined;
scope: "today" | "sprint" | "long";
done: boolean;
createdAt: number; // ms since epoch
completedAt?: number | undefined;
dueAt?: number | undefined;
sprintId?: string | undefined; // set iff scope === "sprint"
}
// One planning sprint. activeSprintId in ProclivityState points at the
// current sprint; sprints past their endsAt are "archived" but kept.
interface Sprint {
id: string;
name: string;
startsAt: number; // ms since epoch (local midnight)
endsAt: number;
}
// One row in a Gantt chart. parentId creates a tree; collapsed hides subtree.
interface GanttTask {
id: string;
chartId: string;
parentId?: string | undefined;
title: string;
startsAt: number;
endsAt: number;
progress: number; // 0–100; forced to 100 in UI when done is true
done: boolean;
collapsed?: boolean | undefined;
color?: string | undefined;
}
// A named container for a set of GanttTasks.
interface GanttChart {
id: string;
name: string;
createdAt: number;
}
// One alarm entry. The service worker owns alarm creation; fired=true
// means the alarm has been delivered and should not re-arm.
interface Reminder {
id: string;
title: string;
fireAt: number; // ms since epoch
recurrence?: "daily" | "weekly" | "none" | undefined;
fired?: boolean | undefined;
linkedTodoId?: string | undefined;
}
// The entire persisted state stored under one chrome.storage.local key.
interface ProclivityState {
todos: Todo[];
sprints: Sprint[];
activeSprintId?: string | undefined;
ganttCharts: GanttChart[];
ganttTasks: GanttTask[];
reminders: Reminder[];
}- All work runs directly on
main. No feature branches, no PRs. SeeCLAUDE.mdfor the full branching policy and commit conventions. npm run buildis the verification bar. It must pass cleanly (tsc -b && vite build) before a change is done. Do not merge code that fails typecheck.- Strict TypeScript flags are non-negotiable. Do not disable
strict,exactOptionalPropertyTypes, ornoUncheckedIndexedAccessto make code compile. - Keep the initial chunk small. The newtab chunk should stay under ~200 kB.
Heavy dependencies (like three.js) must be lazy-loaded via
React.lazy+Suspense. Do not add new npm dependencies without justification. - Sections are all mounted, only hidden.
App.tsxkeeps all five section components mounted at all times and hides inactive ones withhidden={...}. This is deliberate — switching tabs preserves local component state (drafts, expanded archived sprints, etc.). Do not break this with lazy-loaded sections unless you handle the state preservation explicitly. - Agent skills. Two Claude Code skills are available for planning and
execution:
.claude/skills/roadmap— turns a brief into a sequencedplans/<slug>-roadmap.mdwith milestone identifiers..claude/skills/milestone-pipeline— executes one milestone end-to-end through Research → Implement → Critique → Rectify with sub-agent orchestration.
- Today / Long-term todo lists
- Sprint as a real entity (start/end dates, active sprint, per-sprint task scope, archived sprint history)
- Reminders UI wired to
chrome.alarms+chrome.notifications - Gantt: multiple charts, tasks with start/end, nesting via
parentId, collapse/expand, drag-to-move and drag-to-resize bars - Animated wireframe-mesh background with time-of-day color cycle
- Drag-and-drop between sections (e.g. promote a today task to a sprint)
- Daily / weekly review prompts
- Due-date support and overdue indicators on todo items
- Gantt task progress editing in the UI (the field exists in the data model)
- Gantt task color picker
chrome.storage.local has a quota of approximately 10 MB. The data model is
composed entirely of small records — todos, sprints, Gantt tasks, reminders are
each a handful of scalar fields — so normal usage is well within that limit. The
one design to avoid is unbounded append-only growth: long-term activity logs,
audit trails, or history of completed items should be pruned or capped rather
than accumulated indefinitely. If Gantt charts grow into hundreds of tasks, or
reminder history is never cleared, monitor the storage footprint with
chrome.storage.local.getBytesInUse() during development.