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.
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
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
| Name | Duration | Use |
|---|---|---|
| card state | 220ms ease-out | Task text fades in from 6px below and leaves 6px above |
| progress dot | 200ms | Colour change, and scale-125 on the current task |
| theme | 200ms ease-out | background-color and color on body when dark mode toggles |
| celebration | 3000ms | Full-screen celebration when the third task completes, then it dismisses itself |
| minimal tint | 1800ms | Brand tint that arrives, holds and fades, on milestone days only |
| reduced motion | 0ms | Card transitions become instant. Confetti, Stars and Fireworks give way to Minimal |
Layering
| Layer | Class | Use |
|---|---|---|
| content | z-10 | Header, notices and the focus card |
| particles | z-40 | Confetti, Stars and Fireworks |
| overlay | z-50 | Alert dialogs, the settings menu, and the Focus and Minimal celebrations |
| toast | z-[100] | Toast viewport, above every overlay |
| back to top | z-[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.
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
- One field at a time, labelled "Task 1 of 3". Enter or Next moves to the next slot once this one has text
- With all three written, the button reads "Set My 3 Tasks" and Enter commits from any slot
- The three are saved together in one write
- A first launch adds one line of guidance: "The day counts when all three are done."
- 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
- Tap Mark Complete
- The dot turns green, the task text turns green with a strikethrough, and the button reads Completed
- The status line updates: "2 of 3 tasks today"
- On 3/3: the celebration plays, the streak increments, and the card becomes "You're done for today." showing all three
- 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.
Dark Mode
- Class-based:
.darkon<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.
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 (
installIdstripped 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
appStateChangelistener firesrevalidateStreakNow()on resume - Web:
document.visibilitychangefires 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
Storage Keys
| Key | Type | Description |
|---|---|---|
| streak-state-v2 | JSON | Primary StreakState (13 fields) |
| streak-app-data | JSON | v1 format. Converted to streak-state-v2 once on load, then cleared |
| streak-task-draft | JSON | The three slots before they are committed, scoped to the day they were written |
| streak-install-id | string | UUID per install |
| streak-first-install-at | string | ISO timestamp of first launch |
| streak-task-history | JSON | Recent task texts (max 50, localStorage only). A backup carries the three most recent. |
| dark-mode | string | 'true' / 'false' |
| celebration-style | string | 'confetti' / 'stars' / 'fireworks' / 'minimal' / 'focus' |
| last-backup-date | string | ISO timestamp, drives 7-day backup reminder |
| streak-hydration-diag | JSON | Hydration details shown on the App Info screen |
| streak-debug | string | 'true' turns on debug logging |
Streak Increment Logic
Milestone Toasts
| Days | Title | Message |
|---|---|---|
| 3 | 3 Day Streak | Three days complete. Solid start. |
| 7 | One Week Streak | One week complete. Momentum building. |
| 14 | Two Week Streak | Two weeks complete. Staying consistent. |
| 21 | 21 Day Streak | Three weeks complete. Habit forming. |
| 30 | 30 Day Streak | Thirty days complete. One month steady. |
| 50 | 50 Day Streak | Fifty days complete. Strong focus. |
| 75 | 75 Day Streak | Seventy-five days complete. Long run. |
| 100 | Century Club | One 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.