This document specifies the next phase of implementation. It is grounded in the research synthesis from 2026-03-25 and the current state of the codebase. Everything here is buildable against the existing stack (Next.js 16, Dexie, OpenAI) without introducing new infrastructure.
Items are grouped by concern. Each section specifies what to build, where in the codebase it lives, and what the user experience is. UI copy is exact unless marked as example.
Problem: The loops page is empty until 3 events cluster. Users see nothing happening and conclude the app is broken or useless. The current empty state ("no loops yet") is passive and offers no evidence that the system noticed anything.
Principle: The cold start is not waiting — it is the system listening. Every log is a thread. The user should see the threads accumulating before they form a loop.
Replace the current single empty state with three phases based on structuredEvents count and loop_type matches.
Phase 0 — No events logged
[animated ∞ symbol — slow 3s pulse, not static]
chrysalis is listening.
log what just happened and the system
will start to find what's underneath it.
The ∞ animation: use a CSS stroke-dashoffset animation on an SVG path, cycling the dash across the symbol over 3s, giving a "tracing" effect. Not a scale pulse — it should read as moving/alive, not breathing.
Phase 1 — 1–2 events logged, no loop yet
[same ∞, still animated]
1 thread captured.
the pattern takes shape at 3.
Count is live: reads from db.structuredEvents.where('userId').equals(USER_ID).count(). Updates without page refresh via Dexie liveQuery.
Phase 2 — 2 events of same loop_type, not yet clustered (frequency < 3)
Surface the loop_type label from the two matching structured events. Render a ghost loop card — structurally identical to a real loop card but:
- Background: 30% opacity, no border
- Name: the
loop_typelabel, title-cased, in italic — "Task Avoidance" - Frequency badge: shows "×2" in muted color
- Rewiring bar: absent
- Pattern tags: absent
- Overlay text centered on the card: "one more thread and it crystallizes"
This is not fake data. The loop_type label is real extraction output already stored in StructuredEvent. The ghost card reveals what the system already knows is forming.
Implementation:
- Query:
db.structuredEvents.where('userId').equals(USER_ID).toArray()then group byloop_typelabel (stored as... see §1.2 below) - Ghost card is a new component
GhostLoopCard— takes{loopType: string, count: number} - Render below Phase 1 accumulator text if a
loop_typehas ≥2 events
Re-analyze button: Hide when there are no real loops. Show only when loops.length > 0.
Currently loop_type is returned from /api/extract but only used for embedding. It must be persisted on StructuredEvent in Dexie to power the ghost card and the "pattern forming" hint.
Change: Add loopType: string field to StructuredEvent type in lib/db.ts. Store it when saving the structured event in InputScreen.tsx (after extraction returns).
No migration needed — Dexie will add the field to new records. Old records without loopType are ignored in ghost card logic.
After runLoopDetection() in ConfirmationScreen.handleConfirm() returns no match, check whether the current event's loopType already exists in at least one other structured event. If yes, and if this is the 2nd occurrence:
Show a quiet line below the confirmation actions:
we're starting to see a pattern here.
Style: --text-3, small, centered, no icon. Fades in with animate-fade-in delay-300.
This is a display-only change. No new state, no new API call. The data is already there.
Current behavior: The IdentityRecap component only renders when interruption rate > 0 AND frequency ≥ 3 AND interruptions ≥ 2. Most first-time users never see it.
Change: After the first acted_on: true reinforcement event (regardless of loop frequency), show a single sentence in the Done screen instead of "new groove.":
you interrupted a pattern today.
that's how grooves change.
Implementation:
- In
ConfirmationScreen.tsx, after savingReinforcementEventwithinterrupted: true, query totalReinforcementEventcount for this user withinterrupted: true. - If count === 1 (this is the first ever), pass a prop
firstInterruption: trueto the Done state. - In the done display (currently in
app/page.tsx), render the two-line message instead of "new groove." forfirstInterruption.
Animation: same fade-up as current done screen, no other changes.
Current: "I did it" / "not now"
Change: "I tried" / "not ready"
"I tried" is honest — the user may have attempted the intervention behavior without fully executing it. "not ready" removes the implicit "...to do the right thing" guilt of "not now." Both changes are consistent with the non-judgmental register throughout the app.
This is a two-word change in InterventionScreen.tsx.
The /api/intervene prompt hasn't been tuned. Intervention quality is the primary retention mechanism. The following changes are to app/api/intervene/route.ts system prompt only — no structural changes.
Add to the prompt:
-
Self-distancing opener for Reframe type: Begin reframe interventions with a third-person observation: "Someone running this loop is usually trying to protect something." Then pivot to specifics. This reduces emotional reactivity and creates observing distance (Kross self-distancing research).
-
Implementation intention for Action Override type: End action override interventions with a specific if-then plan, not just a directive. Not "do one application now" but "the next time [trigger] shows up, you'll [specific action] before closing the tab." Specificity creates pre-loaded automaticity.
-
Values anchor when available: If the loop has a
valuesAnchorfield (see §3.3), include it: "You said this keeps getting in the way of [values anchor]. That's why this one matters." -
Previous log context: The
/api/intervenecall already acceptshistoryparam but it's passed as an empty string in most cases. Populate it with the raw text of the 2 most recent events matching this loop. The model should reference it: "last time this happened, you said..."
Phase 2 / deferred until push notifications are available. Note the trigger mechanism for when it's built:
- Trigger:
ReinforcementEventcount for any loop changes by ≥1 in the past 7 days - Content: "You interrupted your [loop name] [N] time(s) this week." — no call to action, no log prompt
- Never fires if no reinforcement events exist
- Maximum once per week per loop
This is not a "please log more" notification. It closes the loop on the user's existing behavior.
Replace the current quick taps with defusion-framed alternatives. The user is guided to observe the pattern rather than re-experience it. These still extract cleanly to the same behavioral fields.
Current → New:
| Current | New |
|---|---|
| avoided something | felt the pull and didn't do the thing |
| felt anxious | the thought said no |
| procrastinated | opened it, felt something, closed it |
| got distracted | started something I didn't mean to |
| checked something compulsively | checked again even though I knew |
| snapped at someone | said something I already regret |
The new labels are written as first-person observations from a slight distance — the user is narrating, not re-living. The extraction prompt handles the rest.
Implementation: Update the quick tap array in InputScreen.tsx. The values change; the UI structure does not.
Change to /api/extract/route.ts system prompt:
For the thought field, change the extraction instruction from "what did the user think" to "what did their mind tell them was true in that moment — about themselves, the situation, or what would happen."
This produces defused thought content: "I'm going to fail anyway" rather than "thought about failure." The defused form is more therapeutically useful (it's what the brain actually said) and it serves the Reframe intervention type better.
No schema change. Confidence semantics unchanged.
After a loop reaches ≥3 events and fires its first intervention, add a one-time prompt to the Done screen:
one thing:
what does this pattern keep getting in the way of?
Below: a single text input (no label, placeholder: "e.g. being someone I trust"). Submit button: "save it". Dismissible with a small "skip" link.
Data model change: Add valuesAnchor: string | null field to Loop in lib/db.ts. Store the response via updateLoop().
Usage: The values anchor is passed to /api/intervene and /api/identity as context. Used in intervention copy (see §2.3) and identity recap.
This prompt fires once per loop, ever. Track with a valuesAnchorAsked: boolean flag on Loop.
When the extraction returns an emotion field with confidence ≥ 0.70, show the emotion label for ~1.5s before the full confirmation screen renders. Not a separate screen — an overlay or pre-render state on the confirmation screen.
[emotion label, large serif, centered]
anxiety / shame
The label fades in (0.3s) then the full confirmation screen fades in behind it (after 1.5s, or on tap). The label remains visible as the first field in the confirmation.
Why: Naming an emotion reduces amygdala activation (Lieberman 2007 fMRI). This is a therapeutic function, not decoration. The 1.5s is not a delay — it is the intervention.
Implementation: In ConfirmationScreen.tsx, add a showEmotionBeat state. If emotion.confidence >= 0.70, render the emotion label first with a 1.5s auto-advance timer. The timer is cancellable on tap (skip the beat, go straight to confirmation). If no high-confidence emotion, no beat.
Before the intervention card renders on the intervention screen, show a single line:
notice where you feel it.
Style: --text-3, small, centered, displayed for 1s before the intervention card fades in. Not skippable (it's 1 second). No button.
Implementation: Add a showEmbodiedCue state in InterventionScreen.tsx. On mount, show the cue line for 1000ms, then set state to show the full intervention card. The cue fades out as the card fades in.
This adds 1 second to the intervention flow. It is not a grounding pause (that's already there). It is a somatic focusing technique — directing attention to physical sensation before a cognitive prompt lands. The research rationale is state-dependent encoding: bodily attention at the moment of a new association improves retention of the association.
After an intervention is marked acted_on: true, schedule a check-in for 24 hours later.
Implementation (browser-based, no push notifications):
- On
acted_on: true, storecheckInAt: Date.now() + 86400000on theInterventionrecord. - On app open (in
app/page.tsxuseEffect), query for any intervention wherecheckInAt <= Date.now()andcheckInShownis not true. - If found, before rendering the input screen, show the check-in screen (new
CheckInScreencomponent).
Check-in screen:
yesterday, you tried something.
[loop name]
what happened after?
Free text input. Submit → stored as a JournalEntry (see §5) linked to the intervention. Dismissible with "skip". After submit or skip, mark checkInShown: true on the intervention and proceed to normal input screen.
This is the spaced repetition mechanism. The check-in fires once, 24h post-intervention. It does not re-generate a new intervention — it closes the narrative on the first one.
Data model changes:
Intervention: addcheckInAt: number | null,checkInShown: booleanJournalEntry(see §5): addinterventionId: string | nullas optional link
Add to lib/db.ts:
interface JournalEntry {
id: string;
userId: string;
createdAt: number;
prompt: string; // the question that was asked
response: string; // free-text user response
entryType: "end_of_day" | "check_in" | "values_anchor";
linkedLoopId?: string; // optional
linkedInterventionId?: string; // optional
}Add to Dexie schema: journalEntries: "++id, userId, createdAt, entryType".
No extraction. No embedding. No loop contribution. Journal entries are narrative, not signal.
A light evening reflection mode, separate from the main logging flow. Does not replace in-the-moment logging — it complements it.
Entry point:
- A "reflect" tab added to nav (between "loops" and "settings"), or accessible from the loops page via a quiet link: "end of day →"
- Route:
/app/reflect/page.tsx
Flow:
The reflect page loads the 3 most recent RawEvent entries from today (same calendar day, user's timezone). For each, shows the raw text in a quiet block:
today you logged:
"opened LinkedIn, felt like shit, closed it"
"didn't apply again, just sat there"
"felt the pull and closed the tab"
Below, a single prompt — one of four, rotated daily (day index mod 4):
- "looking at today — what do you notice?"
- "what was hardest today, and what does that say?"
- "what did you try to protect yourself from today?"
- "if tomorrow looked slightly different, what would that feel like?"
These are Pennebaker-style meaning-making prompts, not descriptive prompts.
Free text area (large, no character limit, no extract button). Submit saves a JournalEntry with entryType: "end_of_day". Done state: "saved." — no further action.
No AI in this flow. The reflection is entirely between the user and themselves. Adding extraction here would be a category error — this is for narrative construction, not signal capture.
When to show: The page is always accessible. No push notification gating for MVP. The nav entry point is enough.
A /reflect/history page listing past journal entries by date, readable but not editable. Deferred until storage is expanded beyond IndexedDB. Not in current scope.
The current empty state is stark because it uses the standard --text-3 color on a dark background with no surrounding context. Changes:
- The ∞ symbol: increase from current size to
5rem. Add the SVG trace animation described in §1.1. Color:--accent(honey amber in dark mode) at 60% opacity. - The "chrysalis is listening" text:
--text-1(not--text-3). Lora italic. Slightly larger than current. - The supporting text ("log what just happened..."):
--text-3, normal weight, IBM Plex Mono, max-width constrained to 28ch for comfortable reading. - Spacing: significantly more vertical padding around the empty state block — it should feel like it has room to breathe, not like a footnote.
Problem: The current stage transitions (input → confirming → grounding → intervening → done) are abrupt cuts or fast fades. The flow feels like a form wizard, not a continuous experience.
Changes:
- All stage transitions: change from crossfade to a slide-up + fade where the incoming stage rises from below while the outgoing stage fades and rises slightly. Duration: 350ms. Easing:
cubic-bezier(0.16, 1, 0.3, 1)(expo-out — fast start, gentle land). - The grounding → intervention transition specifically: keep a longer hold (200ms black/empty gap) so the breath genuinely completes before the intervention card arrives. Currently the card can appear before the user has actually exhaled.
- Done screen: the current "logged." text appears cleanly. Keep it. But add a very subtle upward drift (4px over 800ms) so it feels like it's floating rather than just appearing.
Implementation: Add a new transition wrapper component StageTransition that wraps each stage in a div with CSS transition classes. Use a key prop on each stage so React unmounts/remounts properly, triggering enter animations.
/* new in globals.css */
.stage-enter {
opacity: 0;
transform: translateY(12px);
}
.stage-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 350ms cubic-bezier(0.16, 1, 0.3, 1),
transform 350ms cubic-bezier(0.16, 1, 0.3, 1);
}Current: The chain visualization in the expanded loop card shows trigger → emotion → behavior → loop as text with → arrows. It reads like a database dump.
New approach: Render the chain as a connected node diagram — not a technical graph, but an organic, slightly curved horizontal flow.
Each node:
- A rounded pill (border-radius: 999px)
- Background: subtle, per-field color (trigger: blue-tinted, emotion: accent/warm, behavior: violet-tinted, reward: green-tinted) at 15% opacity, with a 1px border at 40% opacity
- Text: the field value, Lora italic, small
- Label above the pill: the field name in
--text-3uppercase tiny
Connection between nodes:
- A faint curved SVG path connecting pill centers (not a straight
→character) - Color:
--text-3at 30% opacity - The path curves slightly downward in the middle (bezier), giving a gravity/flow feeling rather than a mechanical arrow
The final node wraps back with a curved line to the first — a loop. The closing arc is drawn in --accent color at 40% opacity (amber in dark mode), subtly suggesting the cycle.
Implementation:
- New component:
LoopChainVizincomponents/LoopChainViz.tsx - Takes
{trigger, emotion, behavior, reward}string values - Renders pills inline with an SVG overlay for the connecting paths
- SVG paths calculated from pill positions via
getBoundingClientRect()in auseEffectafter render - Falls back to the existing text chain if any field is null
Current: The intervention is displayed in a glass-panel card with a type label, content block, rewiring progress bar, and two buttons. It looks like a feature panel.
New design: The intervention should feel like a message — as if someone who knows you sent it.
Changes:
- Remove the type label ("reframe", "action override") from the visible UI. It reads as technical classification. The type is still stored in the DB; it just doesn't need to be shown to the user.
- Remove the glass panel border and background entirely. The intervention content sits directly on the page background, distinguished by typography alone.
- The intervention text: Lora italic,
1.25rem,--text-1, line-height 1.6. No label. No card chrome. - The loop name pill: move it above the intervention text, small, muted, like a "re:" header on an email — "re: task avoidance".
- The rewiring progress: move below the text, display as a single quiet line rather than a labeled bar:
Five dots, filled proportional to interruption rate. Color:
[●●●○○] rewiring in progress--accentfor filled dots. No percentage number. - The response buttons:
"I tried"and"not ready"(per §2.2). Style:"I tried"is a quiet primary button — not large, not flashy."not ready"is plain text with no button styling, just--text-3color and underline on hover. The asymmetry signals that "I tried" is the intended path without demanding it. - Spacing: generous. The intervention text needs air. Minimum 32px above and below the text block.
The goal: the intervention should feel like a message from a thoughtful person, not a notification from an app. Typography and whitespace do this. Chrome and panels undo it.
- Confirmation screen fields: The colored field labels (trigger, felt, did, thought, reward) currently have a slightly clinical tag appearance. Round the corners more (
border-radius: 6px→border-radius: 12px) and reduce the label font to0.6rem. Make them feel like soft categorizations, not database fields. - Grounding screen: The current copy is "take a breath / you're still here / let's look at this." The third line ("let's look at this") is the weakest — it signals incoming information rather than continuing the grounding. Change to: "something's here." This maintains the pause register and creates gentle curiosity rather than a gear-shift into analysis mode.
- Nav: "chrysalis" brand link — reduce opacity to 70% instead of full
--text-1. The nav should recede, not compete. Active link: use a 2px bottom border in--accentcolor rather than the current opacity change. - Buttons:
btn-primary— reduce from current padding to slightly smaller (py-2.5 px-6). The current buttons feel large against the quiet typography. The intervention buttons especially (see §6.4) should feel proportional to the text, not dominant over it.
The following are deliberately out of scope for this implementation phase:
- Cloud/backend storage (Supabase, Turso, etc.) — Dexie continues to handle all persistence. Journaling adds a new Dexie table, not an external service.
- Push notifications — The weekly recap and check-in timing mechanisms are built but gated on browser notification permission. No native mobile.
- Multi-user / auth — still local-user only.
- The
/app/reflect/historypage — deferred until storage decision. - Ambient detection (Phase 2 in original spec) — unchanged, still deferred.
- Streak mechanics — explicitly excluded. See §2 rationale.
Recommended sequence based on impact vs. effort:
- §1.2 — Store
loopTypeon StructuredEvent (prerequisite for §1.1 and §1.3) - §1.1 — Three-phase empty state (highest visibility gap, pure display change)
- §6.4 — Intervention as message (high emotional impact, moderate effort)
- §2.2 — Button language change (30 seconds, meaningful)
- §6.2 — Softer transitions (affects every interaction, moderate effort)
- §3.4 — Emotion label beat (therapeutic value, low effort)
- §4.1 — Embodied cue line (trivial, high theoretical value)
- §6.3 — Organic chain visualization (visual polish, moderate effort)
- §1.3 — Pattern forming hint on confirmation screen
- §2.1 — First-act identity feedback
- §3.1 — Quick tap defusion framing
- §3.2 — Thought extraction prompt upgrade
- §3.3 — Values anchor prompt + data model
- §2.3 — Intervention prompt upgrades (intervention quality)
- §5.1 + §5.2 — JournalEntry model + end-of-day reflect page
- §4.2 — 24h check-in (spaced repetition)
- §6.1 + §6.5 — Empty state warmth + small UI fixes (can be done alongside anything)