From fa9db351d36182a7b596bf8109cdd77e53bfaecd Mon Sep 17 00:00:00 2001 From: Vayun Godara Date: Tue, 14 Apr 2026 17:34:38 +0200 Subject: [PATCH 01/22] =?UTF-8?q?fix:=20daily=20triage=202026-04-14=20?= =?UTF-8?q?=E2=80=94=20Stats=20page=20+=20landing=20TTFB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 5 Notion findings: - **LI-302** MonthlyCalendar cutoff: grid was clipping weeks 2-6 vertically (overflow: hidden). Changed to overflow-x: clip + explicit grid-template-rows for all 6 week rows. - **LI-300** Streak mismatch: TodayBar read denormalized profiles.current_streak; Stats called calculateStreak(). Unified — TodayBar now also uses calculateStreak() in its Promise.all so both surfaces share one source. - **LI-314** StatsPageClient fetched 5000 pacts + 5000 focus sessions then filtered in JS. Replaced with 12 parallel Supabase count queries (head: true). - **LI-334** Stats page polish: added fadeInUp entrance + editorial streak row (promoted count to text-3xl bold, pluralized Best: N day(s), semantic 3-slot layout). Removed AI-slop identical-card hover (translateY) — kept only border-color transition per dashboard calm-motion principle. Replaced deprecated Fire icon with Flame. - **LI-337** Landing TTFB: app/page.js had force-dynamic + server getUser() adding ~3s TTFB. Moved authenticated redirect fully into lib/supabase/middleware.js (with validated returnTo), dropped force-dynamic. Landing now renders as ○ (Static) — verified via build route table. Also: fixed unused `options` destructure in middleware cookie forEach. LI-270 (partnerships N+1) was already fixed in a prior commit — code uses supabase.rpc('notify_partner') with p_recipients array (not a loop). Notion row updated to reflect. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/dashboard/stats/StatsPage.module.css | 40 ++++-- app/dashboard/stats/StatsPageClient.js | 162 +++++++++++++---------- app/page.js | 21 +-- components/MonthlyCalendar.module.css | 7 +- components/TodayBar.js | 27 ++-- lib/supabase/middleware.js | 26 +++- 6 files changed, 165 insertions(+), 118 deletions(-) diff --git a/app/dashboard/stats/StatsPage.module.css b/app/dashboard/stats/StatsPage.module.css index 2891eb9..9eb9651 100644 --- a/app/dashboard/stats/StatsPage.module.css +++ b/app/dashboard/stats/StatsPage.module.css @@ -39,24 +39,37 @@ gap: var(--space-6); } -/* Streak — compact inline summary */ -.streakInline { - font-size: var(--text-sm); - font-weight: 500; - color: var(--text-secondary); - margin: 0; +/* Streak — hero stat row with weighted hierarchy */ +.streakRow { display: flex; - align-items: center; - gap: var(--space-2); + align-items: baseline; flex-wrap: wrap; + gap: var(--space-4); + margin: 0; } -.streakEmoji { - font-size: 1.1rem; +.streakPrimary { + display: inline-flex; + align-items: baseline; + gap: var(--space-2); + font-family: var(--font-display); + font-size: var(--text-3xl); + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.02em; line-height: 1; } -.streakSep { +.streakPrimaryLabel { + font-family: var(--font-sans); + font-size: var(--text-base); + font-weight: 500; + color: var(--text-secondary); + letter-spacing: 0; +} + +.streakMeta { + font-size: var(--text-sm); color: var(--text-tertiary); } @@ -79,6 +92,11 @@ border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); + transition: border-color var(--transition-fast); +} + +.analyticsCard:hover { + border-color: var(--border-default); } .analyticsCard h3 { diff --git a/app/dashboard/stats/StatsPageClient.js b/app/dashboard/stats/StatsPageClient.js index 5169867..a9486d4 100644 --- a/app/dashboard/stats/StatsPageClient.js +++ b/app/dashboard/stats/StatsPageClient.js @@ -1,12 +1,14 @@ 'use client'; import { useState, useEffect, useCallback, useMemo } from 'react'; +import { motion } from 'framer-motion'; import { createClient } from '@/lib/supabase/client'; import { calculateStreak } from '@/lib/streaks'; import MonthlyCalendar from '@/components/MonthlyCalendar'; import EmptyState from '@/components/EmptyState'; import { SkeletonCard, SkeletonText } from '@/components/Skeleton'; -import { Fire } from '@phosphor-icons/react'; +import { Flame } from '@phosphor-icons/react'; +import { fadeInUp } from '@/lib/animations'; import styles from './StatsPage.module.css'; export default function StatsPageClient({ user }) { @@ -25,6 +27,9 @@ export default function StatsPageClient({ user }) { weekAgo.setDate(weekAgo.getDate() - 7); const monthAgo = new Date(now); monthAgo.setMonth(monthAgo.getMonth() - 1); + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + sevenDaysAgo.setHours(0, 0, 0, 0); // Detect the user's IANA timezone so streak calculations bucket // activity into their local day (matches DashboardLayout persistence). @@ -35,62 +40,78 @@ export default function StatsPageClient({ user }) { // Intl API unavailable — fall back to UTC } - // Fetch streak data using the shared calculation from lib/streaks - const streak = await calculateStreak(supabase, user.id, timezone); - setStreakData(streak); + const uid = user.id; + + // Run streak + all count/lightweight queries in parallel. Count + // queries (head: true) return only a count, not rows — a massive + // win over the previous "fetch 5000 rows then filter in JS" pattern. + const [ + streak, + totalPactsRes, + completedPactsRes, + missedPactsRes, + activePactsRes, + thisWeekCompletedRes, + thisMonthCompletedRes, + focusTotalsRes, + thisWeekFocusRes, + thisMonthFocusRes, + firstFocusRes, + recentSessionsRes, + ] = await Promise.all([ + calculateStreak(supabase, uid, timezone), + // Pact counts + supabase.from('pacts').select('*', { count: 'exact', head: true }).eq('user_id', uid), + supabase.from('pacts').select('*', { count: 'exact', head: true }).eq('user_id', uid).eq('status', 'completed'), + supabase.from('pacts').select('*', { count: 'exact', head: true }).eq('user_id', uid).eq('status', 'missed'), + supabase.from('pacts').select('*', { count: 'exact', head: true }).eq('user_id', uid).eq('status', 'active'), + supabase.from('pacts').select('*', { count: 'exact', head: true }) + .eq('user_id', uid).eq('status', 'completed').gte('completed_at', weekAgo.toISOString()), + supabase.from('pacts').select('*', { count: 'exact', head: true }) + .eq('user_id', uid).eq('status', 'completed').gte('completed_at', monthAgo.toISOString()), + // Focus totals — only duration_minutes column, needed for lifetime sum/avg + supabase.from('focus_sessions').select('duration_minutes').eq('user_id', uid), + supabase.from('focus_sessions').select('*', { count: 'exact', head: true }) + .eq('user_id', uid).gte('started_at', weekAgo.toISOString()), + supabase.from('focus_sessions').select('*', { count: 'exact', head: true }) + .eq('user_id', uid).gte('started_at', monthAgo.toISOString()), + // Earliest session for avg-per-day denominator + supabase.from('focus_sessions').select('started_at').eq('user_id', uid) + .order('started_at', { ascending: true }).limit(1), + // Recent 7 days of sessions for the "Recent Focus Sessions" list + supabase.from('focus_sessions').select('id, started_at, duration_minutes, ended_at') + .eq('user_id', uid).gte('started_at', sevenDaysAgo.toISOString()) + .order('started_at', { ascending: false }).limit(20), + ]); + + if (totalPactsRes.error) throw totalPactsRes.error; + if (focusTotalsRes.error) throw focusTotalsRes.error; + if (recentSessionsRes.error) throw recentSessionsRes.error; - // Fetch pact stats - const { data: pacts, error: pactsError } = await supabase - .from('pacts') - .select('status, completed_at, created_at') - .eq('user_id', user.id) - .order('created_at', { ascending: false }) - .limit(5000); - - if (pactsError) throw pactsError; + setStreakData(streak); - const completedCount = (pacts || []).filter(p => p.status === 'completed').length; - const missedCount = (pacts || []).filter(p => p.status === 'missed').length; - const activeCount = (pacts || []).filter(p => p.status === 'active').length; - const thisWeekCompleted = (pacts || []).filter(p => - p.status === 'completed' && p.completed_at && new Date(p.completed_at) >= weekAgo - ).length; - const thisMonthCompleted = (pacts || []).filter(p => - p.status === 'completed' && p.completed_at && new Date(p.completed_at) >= monthAgo - ).length; + const completedCount = completedPactsRes.count || 0; + const missedCount = missedPactsRes.count || 0; + const activeCount = activePactsRes.count || 0; setPactStats({ - total: (pacts || []).length, + total: totalPactsRes.count || 0, completed: completedCount, missed: missedCount, active: activeCount, - thisWeek: thisWeekCompleted, - thisMonth: thisMonthCompleted, + thisWeek: thisWeekCompletedRes.count || 0, + thisMonth: thisMonthCompletedRes.count || 0, completionRate: completedCount + missedCount > 0 ? Math.round((completedCount / (completedCount + missedCount)) * 100) - : 0 + : 0, }); - // Fetch focus stats - const { data: sessions, error: focusError } = await supabase - .from('focus_sessions') - .select('id, started_at, duration_minutes, ended_at') - .eq('user_id', user.id) - .order('started_at', { ascending: false }) - .limit(5000); - - if (focusError) throw focusError; - - const totalMinutes = (sessions || []).reduce((acc, s) => acc + (s.duration_minutes || 0), 0); - const sessionsCount = (sessions || []).length; - const thisWeekSessions = (sessions || []).filter(s => - s.started_at && new Date(s.started_at) >= weekAgo - ).length; - const thisMonthSessions = (sessions || []).filter(s => - s.started_at && new Date(s.started_at) >= monthAgo - ).length; - const daysSinceFirst = sessionsCount > 0 - ? Math.max(1, Math.ceil((now - new Date((sessions || [])[(sessions || []).length - 1].started_at)) / (1000 * 60 * 60 * 24))) + const focusSessions = focusTotalsRes.data || []; + const totalMinutes = focusSessions.reduce((acc, s) => acc + (s.duration_minutes || 0), 0); + const sessionsCount = focusSessions.length; + const firstStartedAt = firstFocusRes.data?.[0]?.started_at; + const daysSinceFirst = firstStartedAt + ? Math.max(1, Math.ceil((now - new Date(firstStartedAt)) / (1000 * 60 * 60 * 24))) : 1; const avgPerDay = sessionsCount > 0 ? Math.round(totalMinutes / daysSinceFirst) : 0; @@ -98,21 +119,12 @@ export default function StatsPageClient({ user }) { totalMinutes, sessionsCount, avgDuration: sessionsCount > 0 ? Math.round(totalMinutes / sessionsCount) : 0, - thisWeekSessions, - thisMonthSessions, - avgPerDay + thisWeekSessions: thisWeekFocusRes.count || 0, + thisMonthSessions: thisMonthFocusRes.count || 0, + avgPerDay, }); - // Get recent sessions (last 7 days, grouped by day) - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - sevenDaysAgo.setHours(0, 0, 0, 0); - - const recentSessionsData = (sessions || []) - .filter(s => new Date(s.started_at) >= sevenDaysAgo) - .slice(0, 20); - - setRecentSessions(recentSessionsData); + setRecentSessions(recentSessionsRes.data || []); } catch (err) { console.error('Error fetching stats:', err); @@ -203,12 +215,17 @@ export default function StatsPageClient({ user }) { return (
-
+

Your Stats

Track your productivity and progress

-
+
{/* Top-level empty state when user has zero activity */} @@ -233,15 +250,20 @@ export default function StatsPageClient({ user }) { /> )} - {/* Streak Summary — compact inline */} -

- {' '} - {streakData.currentStreak} day streak{' '} - ·{' '} - Best: {streakData.longestStreak} day{' '} - ·{' '} - {streakData.totalCompleted} completed -

+ {/* Streak Summary — hero row */} +
+ + + {streakData.currentStreak} + day streak + + + Best: {streakData.longestStreak} {streakData.longestStreak === 1 ? 'day' : 'days'} + + + {streakData.totalCompleted} completed + +
{/* Activity Calendar */} diff --git a/app/page.js b/app/page.js index b4bd16f..aea005f 100644 --- a/app/page.js +++ b/app/page.js @@ -1,27 +1,10 @@ -import { createClient } from '@/lib/supabase/server' -import { redirect } from 'next/navigation' import LandingPageClient from '@/components/LandingPageClient' -export const dynamic = 'force-dynamic' - export const metadata = { title: 'LockIn - Stop Procrastinating, Start Delivering', description: 'The accountability app that uses social pressure to help you follow through.', } -export default async function HomePage({ searchParams }) { - const supabase = await createClient() - const { data: { user } } = await supabase.auth.getUser() - const params = await searchParams - - if (user && params?.preview !== 'true') { - let destination = '/dashboard'; - const returnTo = params?.returnTo; - if (typeof returnTo === 'string' && returnTo.startsWith('/') && !returnTo.startsWith('//')) { - destination = returnTo; - } - redirect(destination) - } - - return +export default function HomePage() { + return } diff --git a/components/MonthlyCalendar.module.css b/components/MonthlyCalendar.module.css index 5b1566e..6f236db 100644 --- a/components/MonthlyCalendar.module.css +++ b/components/MonthlyCalendar.module.css @@ -102,7 +102,9 @@ /* Calendar Section */ .calendarSection { - overflow: hidden; + /* Clip horizontal slide-animation overflow without hiding vertical rows. + Previously `overflow: hidden` was clipping rows 2-6 of the month grid. */ + overflow-x: clip; } .weekdayRow { @@ -123,6 +125,9 @@ .daysGrid { display: grid; grid-template-columns: repeat(7, 1fr); + /* Explicit 6 rows so every week of the month renders — prevents the + first-row-only cutoff that occurred when rows 2-6 had no guaranteed height. */ + grid-template-rows: repeat(6, minmax(var(--cell-size), 1fr)); gap: var(--cell-gap); } diff --git a/components/TodayBar.js b/components/TodayBar.js index 7d65baf..082e9fc 100644 --- a/components/TodayBar.js +++ b/components/TodayBar.js @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { createClient } from '@/lib/supabase/client'; import { fadeInUp, streakCelebration } from '@/lib/animations'; +import { calculateStreak } from '@/lib/streaks'; import { checkStreakAtRisk, applyStreakFreeze, getStreakFreezeStatus, FREEZE_COOLDOWN_DAYS } from '@/lib/streaks-advanced'; import { fireMilestoneConfetti } from '@/lib/confetti'; import { playStreakMilestone } from '@/lib/sounds'; @@ -131,28 +132,24 @@ export default function TodayBar({ userId, refreshKey, currentStreak, longestStr (sum, s) => sum + (s.duration_minutes || 0), 0 ); - // If last_activity_date is more than 1 day ago, streak is broken - // regardless of what current_streak says (cron may not have reset it). - let streak = profile?.current_streak || 0; - if (streak > 0 && profile?.last_activity_date) { - const now = new Date(); - const todayUTC = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); - const lastActivity = new Date(profile.last_activity_date + 'T00:00:00Z').getTime(); - const daysSince = Math.round((todayUTC - lastActivity) / (1000 * 60 * 60 * 24)); - if (daysSince > 1) streak = 0; - } - - // Resolve user's local timezone for streak risk calculation so - // "at risk" matches the day boundary they see locally. + // Resolve user's local timezone so streak + risk calculations use + // the user's local day boundary (matches Stats page / Share page). let timezone = 'UTC'; try { timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch {} - // Check streak risk and freeze status in parallel - const [risk, freeze] = await Promise.all([ + // Compute streak live from pacts via the shared `calculateStreak` + // helper so Dashboard matches the Stats page. Previously we read + // the denormalized `profiles.current_streak` column, which could + // drift when cron jobs hadn't run yet. Keep profile reads for XP, + // level, and freezes — those aren't re-derivable from pacts. + const [streakResult, risk, freeze] = await Promise.all([ + calculateStreak(supabase, userId, timezone), checkStreakAtRisk(supabase, userId, timezone), getStreakFreezeStatus(supabase, userId), ]); + const streak = streakResult?.currentStreak ?? 0; + setSummary({ dueToday, overdue, diff --git a/lib/supabase/middleware.js b/lib/supabase/middleware.js index ee0c859..360b05a 100644 --- a/lib/supabase/middleware.js +++ b/lib/supabase/middleware.js @@ -15,7 +15,7 @@ export async function updateSession(request) { return request.cookies.getAll() }, setAll(cookiesToSet) { - cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value)) + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) supabaseResponse = NextResponse.next({ request, }) @@ -39,7 +39,29 @@ export async function updateSession(request) { if (user && request.nextUrl.pathname === '/' && !request.nextUrl.searchParams.has('preview')) { const url = request.nextUrl.clone() - url.pathname = '/dashboard' + url.search = '' + + const returnTo = request.nextUrl.searchParams.get('returnTo') + let destination = '/dashboard' + if ( + typeof returnTo === 'string' && + returnTo.startsWith('/') && + !returnTo.startsWith('//') + ) { + try { + const resolved = new URL(returnTo, request.nextUrl.origin) + if (resolved.origin === request.nextUrl.origin) { + destination = returnTo + } + } catch { + // fall through to /dashboard + } + } + + const qIdx = destination.indexOf('?') + url.pathname = qIdx === -1 ? destination : destination.slice(0, qIdx) + if (qIdx !== -1) url.search = destination.slice(qIdx) + const redirectResponse = NextResponse.redirect(url) supabaseResponse.cookies.getAll().forEach((cookie) => { redirectResponse.cookies.set(cookie.name, cookie.value, { From bf5702d8300be3b5c5a2c90582ffdd7e46b3ac67 Mon Sep 17 00:00:00 2001 From: Vayun Godara Date: Tue, 14 Apr 2026 17:45:53 +0200 Subject: [PATCH 02/22] fix: share page speed + streak emoji consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Share button slow**: `/share/streak` ran 3 sequential server queries (auth → profile → 366-day streak scan) with no loading state. Parallelized profile+streak via Promise.all and added `loading.js` Suspense skeleton mirroring the share card so the transition feels designed instead of blank. - **Streak emoji consistency**: Dashboard TodayBar rendered streak as 🔥 emoji; Stats page uses Phosphor ``. CLAUDE.md mandates Phosphor for UI chrome, emojis for content. Unified TodayBar to Phosphor `` matching Stats. Milestone celebration wrapper + iconPulse animation preserved. Also includes an earlier lossless refactor by the impeccable:critique run: Design Context moved from CLAUDE.md into dedicated `.impeccable.md` (108-line design brief that impeccable skills auto-read). Co-Authored-By: Claude Opus 4.6 (1M context) --- .impeccable.md | 108 +++++++++++++++++ CLAUDE.md | 23 +--- app/share/streak/loading.js | 60 ++++++++++ app/share/streak/loading.module.css | 180 ++++++++++++++++++++++++++++ app/share/streak/page.js | 27 +++-- components/TodayBar.js | 10 +- components/TodayBar.module.css | 1 - 7 files changed, 373 insertions(+), 36 deletions(-) create mode 100644 .impeccable.md create mode 100644 app/share/streak/loading.js create mode 100644 app/share/streak/loading.module.css diff --git a/.impeccable.md b/.impeccable.md new file mode 100644 index 0000000..5632e31 --- /dev/null +++ b/.impeccable.md @@ -0,0 +1,108 @@ +# Impeccable Design Context — LockIn + +This file is the single source of truth for LockIn's design DNA. Impeccable skills (`/polish`, `/critique`, `/arrange`, `/animate`, `/audit`, `/delight`, etc.) and the `lockin-frontend` agent both read it to stay grounded in the project's aesthetic direction. + +--- + +## Design Context + +### Users + +University students who procrastinate. They want to stop — but reading-list productivity apps (Todoist, Things 3) feel like chores, and cartoon gamification (Habitica) feels childish. LockIn is their personal accountability system: streaks they've built, pacts they've kept, an XP number only they know the full story of. + +They use it on mobile between classes, on desktop during study sessions, and on weekends when they're catching up. Context is always "I should be doing work and I'm not" — the app exists to convert that friction into motion. + +### Brand Personality + +**Motivating. Personal. Polished.** + +The one-line positioning: *"Duolingo grew up for university."* BeReal + Duolingo DNA (social pressure + gamification), executed with Arc Browser + Linear's editorial restraint. + +The interface should feel like a well-designed sports watch, not a dashboard. It celebrates the user — their streak, their accent color, their level — without being loud about it. Confetti on pact completion is correct; confetti on every page load is a cartoon. + +### Aesthetic Direction + +**Warm editorial.** Light-first with full dark mode. Off-white backgrounds (`#FAF9F7`) with violet undertones, not bright white. Frosted glass surfaces on modals and overlays (intentional — see "Intentional Deviations" below). Typography carries the mood: Instrument Sans display for moments that matter (streak counters, level badges, celebrations), Inter body for utility. + +Color is earned, not decorative. Accents appear on meaningful events — a streak-at-risk amber warning, a completed-today success green, a milestone gold — not as background flourishes. Seven accent palettes (ocean, emerald, sunset, rose, violet, slate, indigo-default) are user-selectable and treated as personal identity, not theme decoration. + +Motion is meaning. Dashboard motion is calm and utilitarian: entrance fades, list staggering, hover elevations. Landing page and celebrations are theatrical: confetti bursts, gradient shifts, streak-celebration animations. Framer Motion 12.x handles both registers via the `@/lib/animations` preset library. + +**References (what to look toward):** +- BeReal — social accountability, friend-first feed +- Duolingo — gamification with character, streak-as-identity +- Arc Browser — warm surfaces, earned color, sidebar as workspace +- Linear — information density without clutter, restraint + +**Anti-references (what to explicitly avoid):** +- Generic SaaS / AI-generated UI — gradient blobs on white, icon grids, identical hovers, purple-to-blue everything +- Minimalist/sterile productivity apps — Todoist, Things 3 (too quiet, no personality) +- Cartoon RPG gamification — Habitica (too childish, breaks credibility for university students) + +### Design Principles + +1. **Earned color, not decoration.** Color appears on meaningful events (streak-risk warning, milestone celebration, urgency tier) — never as page-level flourish. A gray dashboard that blooms with amber when a pact is overdue is correct. A dashboard that's colorful "to feel friendly" is wrong. + +2. **Social proof over empty states.** When there's friend activity, surface it as first-class content — not as an afterthought below a primary task list. Empty states should teach the interface and feel warm, not say "nothing here yet." + +3. **Gamification with taste.** XP, streaks, confetti, level badges — keep all of them, execute them with editorial polish. Never apologize for gamification, never let it become cartoon. The XP ring in the sidebar is correct; a bouncing mascot is wrong. + +4. **Personal, not personalized.** This is *their* app — their streak, their accent color, their level, their pact templates. Identity shows through the interface, not as a profile badge. The accent-colored logo in the sidebar is not branding, it's ownership. + +5. **Motion as meaning.** Every animation should answer "why is this moving?" Entrance reveals teach the layout; stagger hints at ordering; celebration motion marks achievement. Ambient motion (breathing glows, floating gradients) is reserved for landing/marketing surfaces, not dashboard. + +--- + +## Intentional Deviations from Impeccable Defaults + +Impeccable has strong opinions that conflict with LockIn's established brand. These deviations are **deliberate, not oversights**: + +### Fonts: Inter + Instrument Sans + +Both appear on impeccable's `reflex_fonts_to_reject` list. **LockIn uses them anyway** because: +- Inter is the installed app-UI font the project committed to at launch and it carries forward to the iOS SwiftUI app for brand consistency. +- Instrument Sans is the editorial display counterpart that the 2026 redesign codified (see `feedback_duolingo_energy.md`). + +**When building new components:** use `var(--font-sans)` for body, `var(--font-display)` for headings and celebratory moments. Don't quietly swap in a "more distinctive" font per impeccable's font-selection procedure — that creates visual drift from the established brand. + +**If you think LockIn needs a font migration:** raise it as a deliberate project, not a drive-by. The cost is brand disruption across web + iOS. + +### Glassmorphism on Modals + +Impeccable says "DO NOT use glassmorphism everywhere." LockIn uses frosted glass on modals specifically (`--surface-glass`, `--surface-1/2/3` rgba variables) and this is intentional — see `feedback_modal_glass.md`. + +**The rule:** glass belongs on modals and overlays that float above the dashboard (the dashboard should blur through them). Glass does **not** belong on cards, sidebar, buttons, inputs, or page-level surfaces. Those should use solid warm off-whites. + +### Gradients on Brand Surfaces + +LockIn uses `--gradient-primary` (indigo → purple) on brand moments: the logo, the landing hero, the level badge, major CTAs. Impeccable warns against "purple gradients on white" as an AI tell — but LockIn's brand gradient predates that generic pattern and the palette is specifically tuned (`#5B5EF5` → `#7C4DFF` → `#E040CB`, not the generic blue-to-purple SaaS default). + +**Gradient text is still banned** (impeccable's `` applies). Use solid `var(--accent-text)` for text that needs emphasis. Gradients belong on backgrounds, borders, and icon fills. + +### Colors in Hex, Not OKLCH + +`globals.css` uses hex/rgba throughout, not `oklch()`. Impeccable prefers OKLCH for perceptual uniformity, and we agree — but retrofitting 300+ CSS variables is a project, not a PR. When introducing **new** color tokens, prefer OKLCH. When editing **existing** tokens, match the surrounding style (hex/rgba) to avoid mixed-format drift within a single file. + +--- + +## Component Vocabulary + +Key components to reference by name when building or critiquing: + +- **`TodayBar`** — Unified status surface at the top of the dashboard. Replaces the old StreakHero + DailySummaryCard. Contains streak, pacts-due count, focus time, streak-risk warnings, streak-freeze controls, milestone celebrations. First impression of the dashboard — sets the tone for the session. +- **`PactCard`** — Personal commitment card. Four urgency states: overdue (red, elevated), due today (amber), completed (muted + XP badge), future (default). The urgency hierarchy is the primary signal — do not weaken it with decorative color. +- **`Sidebar`** — 72px collapsed / 260px expanded (Arc-style). Contains XP progress ring, accent-colored logo, navigation. The progress ring and logo are personal identity, not chrome. +- **`MobileNav`** — Bottom nav for mobile with level badge. Mirrors sidebar identity elements at mobile size. +- **`ActivityFeed`** — Friend activity with emoji reactions and comments. This is social proof; treat it as first-class content on the dashboard, not a secondary widget. +- **Animation presets** — `@/lib/animations` exports 80+ Framer Motion presets (ambient*, fade*, slide*, scale*, stagger*, celebration*, xpFillFlash, streakCelebration). Use these. Don't define ad-hoc transitions in component files. + +## Accessibility Commitments + +- **WCAG target:** AA. Known open regression on pact contrast (tracked in memory `project_dashboard_redesign.md`). +- **Reduced motion:** Respect `prefers-reduced-motion`. The `prefersReducedMotion()` helper in `@/lib/animations` gates ambient and celebratory animations. +- **Keyboard navigation:** All interactive surfaces reachable by keyboard, focus-visible outlines intact. Keyboard shortcuts documented in `KeyboardShortcutsContext`. +- **Contrast on glass surfaces:** Glass modals must maintain AA contrast against the blurred dashboard. Test with both light and dark themes behind the glass. + +--- + +*This file was generated by `/impeccable teach` on 2026-04-14. Update via the same command when brand direction shifts.* diff --git a/CLAUDE.md b/CLAUDE.md index 4380b88..6567b50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -294,28 +294,9 @@ Design doc: `/docs/plans/2026-03-08-ios-app-design.md` ## Design Context -> **For design work:** Use impeccable skills (`/critique`, `/polish`, `/arrange`, `/animate`, `/audit`, etc.). Full design brief is in `.impeccable.md`. Use `/impeccable teach` if `.impeccable.md` needs updating. +Full design brief lives in [`.impeccable.md`](./.impeccable.md) — users, brand personality, aesthetic direction, references/anti-references, 5 design principles, component vocabulary, and intentional deviations from impeccable defaults (fonts, glassmorphism, brand gradient). -**Users:** University students using social accountability to beat procrastination. The app should feel like their personal system — proud ownership through XP, streaks, and level badges. - -**Brand:** "Duolingo grew up for university." Motivating, Personal, Polished. - -**References:** BeReal + Duolingo (social pressure + gamification DNA), Arc Browser + Linear (design language — warm editorial, strategic color). - -**Anti-references:** Generic SaaS / AI-generated UI (gradient blobs, icon grids, identical hovers), minimalist/sterile (Todoist, Things 3), cartoon RPG (Habitica). - -**Design Principles:** -1. Earned color, not decoration — color appears on meaningful events, not everywhere -2. Social proof over empty states — surface friend activity as first-class content -3. Gamification with taste — XP/streaks/confetti stay, executed with editorial polish -4. Personal, not personalized — feels like their app (their streaks, their accent color) -5. Motion as meaning — dashboard motion is calm/utilitarian, landing is theatrical - -**Key Components:** -- `TodayBar` — replaces StreakHero + DailySummaryCard. Unified status surface with streak, pacts due, focus time, streak-risk/freeze controls, and milestone celebrations. -- `PactCard` — urgency hierarchy: overdue (red/elevated), due today (amber), completed (muted + XP badge), future (default). -- XP visible in Sidebar (progress ring) and MobileNav (level badge), not just dashboard header. -- Icons: `@phosphor-icons/react` for UI chrome. Keep emojis for content (templates, categories). +For design work use impeccable skills (`/critique`, `/polish`, `/arrange`, `/animate`, `/audit`, etc.) — they read `.impeccable.md` automatically. Update via `/impeccable teach` when brand direction shifts. ## Current Status diff --git a/app/share/streak/loading.js b/app/share/streak/loading.js new file mode 100644 index 0000000..9d4efd4 --- /dev/null +++ b/app/share/streak/loading.js @@ -0,0 +1,60 @@ +import styles from './loading.module.css'; + +/** + * Skeleton for /share/streak — Next.js auto-wraps this in Suspense while the + * server component fetches profile + streak data (up to 366 days of pacts). + * Mirrors the ShareStreakClient layout (gradient card, large streak number, + * avatar + name, stats row, tagline, two buttons) so the loading-to-loaded + * transition feels designed rather than janky. + */ +export default function Loading() { + return ( +
+
+ {/* Logo row */} +
+
+
+ + {/* Large streak number + label */} +
+
+
+
+ + {/* Avatar + name */} +
+
+
+
+ + {/* Stats row */} +
+
+
+
+
+