StreakFocus

Living Interaction Design Specification

StreakFocus helps you pick 3 things each day and do them. No categories, no metrics, no guilt. Just a streak that grows when you show up.

Platform

iOS

Stack

React + Vite + Capacitor

Storage

localStorage / Preferences

Last Updated

September 2026

System Specification

This living specification documents the interaction patterns, design constraints, and system decisions that govern how the product behaves.

It serves as the reference for design tokens, component patterns, and interaction rules used across the product.

Product Philosophy

The design thinking behind every decision.

Core Insight

Constraint breeds focus. Pick 3 things. Do them. That's it. The limitation isn't a compromise: it's the product.

Three tasks. That's enough.

Hardcoded 3-task limit prevents overwhelm. You can't add a 4th. Decision fatigue ends at the input screen.

Your day starts at 3 AM

Completions before 3 AM still count for the previous day, so a late night doesn't cost a streak.

Momentum over perfection

Streak mechanics reward showing up, not performance. Completing all 3 counts the same whether they took 5 minutes or 5 hours.

Your data, your device

localStorage and Capacitor Preferences only. No server, no account, no sync. Your streak is yours.

Design Tokens

Every token rendered live with actual values. Toggle dark mode in the sidebar to see both palettes.

Brand Orange

#c94117

primary

#B33610

dark

#F5F0EB

light

#FDF8F6

50

White text measures 4.94:1 on #c94117 and 6.08:1 on #B33610.

Success Green

#1ba14c

DEFAULT

#107a37

dark

#d7f9e3

light

#edfdf2

50

Destructive Red

#B91C1C

light theme

#7F1D1D

dark theme

White text on #B91C1C measures 6.47:1.

Neutral Scale

Light mode: neutral grays. Dark mode: zinc. The scale inverts, so neutral-50 is the lightest value in light mode, the darkest in dark mode, and the app background in both. Light values shown.

50

100

200

300

400

500

600

700

800

900

950

Typography

Inter, single font family. Weights 400 to 900.

xs / 12pxTask 2 of 3
sm / 14px1 of 3 tasks today
base / 16pxA fresh three is waiting tomorrow.
lg / 18pxMark Complete
28pxStreakFocus
3xl / 30pxDone today
5xl / 48pxFocus

The header title is 28px on phones and 30px from 640px. Task text is 30px on phones, 48px from 768px and 60px from 1024px (text-3xl md:text-5xl lg:text-6xl).

Spacing: 8pt Grid

4px
8px
16px
px-4: app side padding on phones
24px
p-6: focus card on phones
32px
md:px-8: app side padding from 768px
40px
md:p-10: focus card from 768px
48px
h-12: button height
96px

Border Radius

tailwind.config.js redefines rounded as 8px and rounded-lg as 16px. The rest follow Tailwind's scale.

rounded
8px, toast

rounded-xl
12px, app tile

rounded-lg
16px, notices

rounded-2xl
16px, buttons

rounded-3xl
24px, card

rounded-full
dots

Elevation

shadow-sm

header buttons

shadow-md

settings menu

shadow-lg

toast

shadow-xl

primary action

shadow-2xl

focus card

Motion

NameDurationUse
card state220ms ease-outTask text fades in from 6px below and leaves 6px above
progress dot200msColour change, and scale-125 on the current task
theme200ms ease-outbackground-color and color on body when dark mode toggles
celebration3000msFull-screen celebration when the third task completes, then it dismisses itself
minimal tint1800msBrand tint that arrives, holds and fades, on milestone days only
reduced motion0msCard transitions become instant. Confetti, Stars and Fireworks give way to Minimal

Layering

LayerClassUse
contentz-10Header, notices and the focus card
particlesz-40Confetti, Stars and Fireworks
overlayz-50Alert dialogs, the settings menu, and the Focus and Minimal celebrations
toastz-[100]Toast viewport, above every overlay
back to topz-[110]Back-to-top button, above toasts so a toast cannot hide it

Touch Targets

44 x 44

48 x 48

WCAG 2.5.5: min 44px for icon buttons, 48px preferred. All interactive elements meet this threshold.

Component Library

Live specimens using production tokens. Source file noted for each.

Primary Action

Source: src/components/FocusModeView.tsx, the one large button on the card in every state

States

Mark Complete: bg-orange-500 text-white, 4.94:1. Completed: bg-success-dark, 5.44:1. Next stays disabled at opacity-60 until the current slot has text.

Button

Source: src/components/ui/button.tsx, cva variants (default, destructive, outline, secondary, ghost, link) with Radix Slot for asChild

Sizes

States

rounded-lg is 16px in this app's scale. Disabled: disabled:opacity-50 disabled:pointer-events-none. Focus ring: focus-visible:ring-[3px] focus-visible:ring-ring/50

Header

Source: App.tsx, a 3-column grid above every state, not sticky

StreakFocus

5 day streak

The app icon, the name, then the streak once it is above zero, or a time-of-day greeting at zero. Dark mode and the settings menu sit on the right.

Focus Card

Source: src/components/FocusModeView.tsx, the app's single surface. One card holds four states: write, do, done for today, and new day.

Task 2 of 3

Write unit tests for streak logic

1 of 3 tasks today

min-h-focus-card rounded-3xl bg-white/95 border-zinc-200/80 shadow-2xl p-6 md:p-10. min-h-focus-card is 33rem, so every state renders at the same size and in the same place. Dots are 10px inside 44px targets: green is complete, orange is current.

Done for Today

Source: src/components/FocusModeView.tsx, the card once all three are complete

You're done for today.

Day 6 complete. A fresh three is waiting tomorrow.

  • Review PR for auth module
  • Write unit tests for streak logic
  • Update project README

The only place the three appear together. It reads rather than acts: no controls, nothing to decide.

Toast

Source: src/components/ui/toast.tsx, Radix Toast, bottom-anchored. Milestone toasts fire at 3, 7, 14, 21, 30, 50, 75 and 100 days (STREAK_MILESTONES in useStreak.ts).

3 Day Streak

Three days complete. Solid start.

Streak reset

Starting fresh. Set three tasks for today.

Alert Dialog

Source: src/components/ui/alert-dialog.tsx, Radix AlertDialog for destructive confirmations

Reset your streak?

This will permanently reset your streak to 0 and clear all tasks. This action cannot be undone.

System Notices

Source: App.tsx for the update and backup banners between header and card, and src/components/SaveFailedNotice.tsx above the card

Update available

A new version is ready. Tap Reload to get the latest features.

Time for a backup

It's been 7+ days since your last export. Create a backup when convenient.

Your tasks aren't saved.

This device isn't storing them right now. They may be gone next time you open StreakFocus. You can still export a backup.

The save notice accompanies the card rather than replacing it. The task is still doable, and the next successful write clears the notice.

Interactive Prototype

A clickable phone frame with the app's states. Write three tasks, mark them complete, move between tasks with the dots, and edit a task with the pencil.

9:41

First Launch

Interaction Patterns

Recurring behaviors that define the app's feel.

Focus Surface

  • The app’s only surface, in flow beneath the header: min-h-focus-card, one card size in every state
  • Four states: write the three, do them one at a time, done for today, and new day
  • Opens on the first unfinished task
  • Swipe navigation: 50px threshold, swipe left for the next task and right for the one before. Each progress dot jumps straight to its task
  • Respects prefers-reduced-motion: card transitions become instant

Writing the Three

  1. One field at a time, labelled "Task 1 of 3". Enter or Next moves to the next slot once this one has text
  2. With all three written, the button reads "Set My 3 Tasks" and Enter commits from any slot
  3. The three are saved together in one write
  4. A first launch adds one line of guidance: "The day counts when all three are done."
  5. The draft saves as you type (400ms debounce) and comes back if the app closes before you commit, until the 3 AM boundary

Task Completion Flow

  1. Tap Mark Complete
  2. The dot turns green, the task text turns green with a strikethrough, and the button reads Completed
  3. The status line updates: "2 of 3 tasks today"
  4. On 3/3: the celebration plays, the streak increments, and the card becomes "You're done for today." showing all three
  5. Editing in place: the pencil beside "Task 2 of 3", or a tap on the task text, turns the text into a field. Enter saves, Escape cancels

Celebration System

5 user-selectable styles in the settings menu. Each plays when the third task completes and dismisses itself after 3000ms.

Confetti (13 particles, 30ms stagger) Stars (15 Sparkles icons, 40ms) Fireworks (20 radial bursts, 25ms) Minimal (brand tint, milestone days only) Focus (dark overlay, a ring draws around the streak number)

Dark Mode

  • Class-based: .dark on <html>
  • Transition: background-color 200ms ease-out, color 200ms ease-out
  • Manual toggle only, no system preference detection
  • Persisted: localStorage['dark-mode']
  • Toggled from the header button

Day Boundary (3 AM)

getDayKey(date, dayStartHour=3) shifts the date back by 3 hours before extracting YYYY-MM-DD. A task completed at 1:30 AM Tuesday counts as Monday.

Mon 3:00 AM Tue 2:59 AM = Monday's day

When the day rolls over after a completed day, the card reads "Yesterday is complete." with a Refresh button that opens today's three. The streak stays intact.

Returning After a Miss

A streak at zero after a completed day is acknowledged on the write state, never ignored. Source: src/utils/zeroStateMessage.ts. Three messages escalate with the length of the gap:

  • "Days get missed." / "Pick up where you left off."
  • "Streaks break." / "This is a fresh start."
  • "Starting from scratch." / "Three tasks. That's enough." (two weeks or more)
  • Beneath each: "Your best streak is still 12 days."

Backup / Restore

  • Export: v2.0 JSON with full StreakState (installId stripped for privacy)
  • Import: strict validation (version must be "2.0", required fields type-checked)
  • Confirmation AlertDialog before import (destructive action)
  • Native: Capacitor Filesystem + Share sheet. Web: Blob → createObjectURL → download
  • Backup reminder banner after 7+ days since last export

App Lifecycle

  • Native: Capacitor appStateChange listener fires revalidateStreakNow() on resume
  • Web: document.visibilitychange fires on tab visibility
  • Foreground day rollover: 60s interval checking getDayKey()
  • Revalidation throttle: 30s minimum between checks (bypassed if day changed)
  • A rollover after a completed day sets needsRefresh, which shows the new-day state until Refresh

Data Architecture

localStorage (web) and Capacitor Preferences (native). No server, no sync.

StreakState Schema v2

interface StreakState { schemaVersion: number; // = 2 currentStreak: number; bestStreak: number; tasks: Task[]; // always exactly 3 allTasksCompleted: boolean; needsRefresh: boolean; // day rolled, awaiting Refresh tasksDayKey: string; // YYYY-MM-DD tasks belong to lastCompletionDayKey: string; // last all-complete day lastEvaluatedDayKey: string; // revalidation idempotency lastCountedDayKey: string; // streak increment idempotency dayStartHour: number; // default: 3 (3 AM) installId: string; // UUID per install lastSavedAt: string; // ISO timestamp } interface Task { id: string; // 'task-1' | 'task-2' | 'task-3' text: string; // no length cap, wraps on the card completed: boolean; }

Storage Keys

KeyTypeDescription
streak-state-v2JSONPrimary StreakState (13 fields)
streak-app-dataJSONv1 format. Converted to streak-state-v2 once on load, then cleared
streak-task-draftJSONThe three slots before they are committed, scoped to the day they were written
streak-install-idstringUUID per install
streak-first-install-atstringISO timestamp of first launch
streak-task-historyJSONRecent task texts (max 50, localStorage only). A backup carries the three most recent.
dark-modestring'true' / 'false'
celebration-stylestring'confetti' / 'stars' / 'fireworks' / 'minimal' / 'focus'
last-backup-datestringISO timestamp, drives 7-day backup reminder
streak-hydration-diagJSONHydration details shown on the App Info screen
streak-debugstring'true' turns on debug logging

Streak Increment Logic

// When all 3 tasks completed (useStreak.ts): if (alreadyCountedToday) { newStreak = currentStreak; // idempotent } else if (!lastCompletionDayKey) { newStreak = 1; // first completion } else if (wasDayKeyYesterday(lastCompletionDayKey)) { newStreak = currentStreak + 1; // consecutive } else { newStreak = 1; // gap, start fresh } bestStreak = Math.max(newStreak, bestStreak); lastCountedDayKey = todayKey; lastCompletionDayKey = todayKey;

Milestone Toasts

DaysTitleMessage
33 Day StreakThree days complete. Solid start.
7One Week StreakOne week complete. Momentum building.
14Two Week StreakTwo weeks complete. Staying consistent.
2121 Day StreakThree weeks complete. Habit forming.
3030 Day StreakThirty days complete. One month steady.
5050 Day StreakFifty days complete. Strong focus.
7575 Day StreakSeventy-five days complete. Long run.
100Century ClubOne hundred days complete. Sustained streak.

Shown 4 seconds after the day counts, for 6 seconds.

Decision Log

Architectural decisions with rationale. The "why" behind the product.

Exactly 3 tasks

Not 1, not 5, not configurable.

Context: People with ADHD, and anyone who shuts down when there's too much to track, stall on long task lists. Too many items create paralysis; too few feel trivial.

Decision: Hardcode exactly 3 tasks. No setting to change it. Task IDs are canonical: task-1, task-2, task-3.

Rationale: 3 is the smallest number that feels like "enough" while being completable in any day. The constraint is the feature: it forces prioritization.

Trade-off: Power users who want more will need a different app. That's intentional.

3 AM day boundary

Over midnight.

Context: People who work late finish tasks after midnight. A midnight cutoff would count that work toward the wrong day and break a streak they kept.

Decision: getDayKey() subtracts dayStartHour (default 3) hours before extracting YYYY-MM-DD.

Rationale: A task finished at 1 AM counts toward the day it was planned for, not the next one.

Trade-off: A person whose day starts before 3 AM has those early completions counted toward the day before. One fixed boundary keeps the rule explainable in a sentence.

Device-only storage

No server, no sync, no account.

Context: Users distrust habit apps that require accounts. Habit data feels private: sharing it with a server creates friction and trust barriers.

Decision: Data stays on the phone, in Capacitor Preferences and the app's local storage. It leaves only when the user exports a JSON backup through the share sheet, and importing that file restores it.

Rationale: Zero infrastructure cost, zero privacy liability, instant startup. The JSON backup covers the "new phone" scenario.

Trade-off: No cross-device sync. Deleting the app without exporting first loses the streak, so a backup reminder banner prompts an export.

Focus as the whole app

One task and one action at a time.

Context: Even three tasks shown side by side invite re-reading, re-sorting and second-guessing. People who struggle to start need the next action, not a menu of options.

Decision: The app is one card beneath the header. The same card holds writing the three, doing them one at a time, the finished day, and the start of a new day. Each state shows one primary action.

Rationale: Every screen presents a single decision, so starting costs one tap. Large task text and three progress dots give structure without adding choices.

Trade-off: The three appear together only once the day is done. Seeing another task mid-day takes a swipe or a tap on its dot, which is the point: the other two wait their turn.

Celebration animations as positive reinforcement

Not gamification.

Context: Completing all 3 tasks is the app's core moment. It deserves more than a checkbox flip.

Decision: 5 user-selectable celebrations. Full-screen, brief (3s), automatic. Under reduced motion, Confetti, Stars and Fireworks give way to Minimal, and Focus keeps its screen with fades only.

Rationale: Positive feedback loops without points, badges, or leaderboards. The celebration is personal, not competitive. User choice of style makes it feel owned.

Trade-off: Could feel juvenile to some. Minimal, a brief brand tint on milestone days only, provides a quiet alternative.

Schema v2 with idempotency guards

lastCountedDayKey + lastEvaluatedDayKey.

Context: App lifecycle events (resume, visibility change, the 3 AM rollover check) can trigger revalidation multiple times per day. Without guards, streaks could double-increment.

Decision: Two idempotency keys: lastCountedDayKey prevents double streak increment, lastEvaluatedDayKey prevents redundant revalidation within a day.

Rationale: Belt and suspenders. Streak integrity is the app's most critical invariant: a streak that randomly increments or resets destroys user trust.

Trade-off: More complex state, harder to reason about. The App Info screen shows the stored streak state, which keeps it inspectable.

Edit a task in place

Source: FocusModeView.tsx, the pencil beside the task label.

Context: People set their three early and then notice one is worded badly or is bigger than it looked. Retyping all three to fix one adds friction at the moment someone is trying to start.

Decision: On the do state a pencil sits beside "Task n of 3", and tapping the task text does the same. The text becomes a field: Enter saves, Escape cancels. Completion state is untouched.

Rationale: The pencil reads as belonging to the task it edits, and it adds no row and no words to the card. An Edit button under the task would put a second action in the primary action's path.

Trade-off: Editing closes once all three are done. The finished day is a record of what was completed, so its three stay as they were.

One card size in every state

Source: tailwind.config.js, min-h-focus-card (33rem).

Context: A card sized to its content shrinks as the day goes on, and the finished day would be the smallest, lowest box on screen. The payoff moment would read as the app deflating.

Decision: Every state renders in one card with a minimum height of 33rem, centred in a container of constant height, measured against the tallest state at 320, 390 and 430px. It is a minimum rather than a fixed height, so a long task title still grows the card.

Rationale: The card is the fixed point of the app. Content changes inside it while the frame holds still, so completing the last task is an arrival rather than a jump.

Trade-off: Short states such as the new-day card carry more empty space than their content needs. Accepted so that no state sits smaller or lower than another.