From e769c6ff3887b21bc85be2ac49a3d8c8054c95a7 Mon Sep 17 00:00:00 2001 From: IshitaSingh0822 Date: Fri, 15 May 2026 13:03:55 +0530 Subject: [PATCH 1/5] feat: add streak freeze feature (#37) --- src/app/api/streak/freeze/route.ts | 65 +++++++++++++++++++ .../20260515000000_add_streak_freezes.sql | 12 ++++ 2 files changed, 77 insertions(+) diff --git a/src/app/api/streak/freeze/route.ts b/src/app/api/streak/freeze/route.ts index 317b679f5..735edab3f 100644 --- a/src/app/api/streak/freeze/route.ts +++ b/src/app/api/streak/freeze/route.ts @@ -1,3 +1,4 @@ +<<<<<<< HEAD import type { NextRequest } from "next/server"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; @@ -9,6 +10,11 @@ import { metricsCacheKey, withMetricsCache, } from "@/lib/metrics-cache"; +======= +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) export const dynamic = "force-dynamic"; @@ -16,14 +22,20 @@ function todayStr(): string { return new Date().toISOString().slice(0, 10); } +<<<<<<< HEAD // GET /api/streak/freeze // Returns whether the user currently has an unused freeze available. export async function GET(req: NextRequest) { +======= +// Returns whether the user currently has an unused freeze available. +export async function GET() { +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) const session = await getServerSession(authOptions); if (!session?.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } +<<<<<<< HEAD const user = await resolveAppUser(session.githubId, session.githubLogin); if (!user) return Response.json({ error: "User not found" }, { status: 404 }); @@ -43,21 +55,40 @@ export async function GET(req: NextRequest) { } async function getFreezeStatus(userId: string) { +======= + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) const today = todayStr(); const { data: pending } = await supabaseAdmin .from("streak_freezes") .select("id, freeze_date") +<<<<<<< HEAD .eq("user_id", userId) +======= + .eq("user_id", user.id) +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) .gte("freeze_date", today) .limit(1); const hasFreeze = Array.isArray(pending) && pending.length > 0; +<<<<<<< HEAD return { hasFreeze, freezeDate: hasFreeze ? pending![0].freeze_date : null }; } // POST /api/streak/freeze +======= + return Response.json({ hasFreeze, freezeDate: hasFreeze ? pending![0].freeze_date : null }); +} +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) // Inserts a freeze for today. Fails if the user already holds an unused freeze. export async function POST() { const session = await getServerSession(authOptions); @@ -65,11 +96,21 @@ export async function POST() { return Response.json({ error: "Unauthorized" }, { status: 401 }); } +<<<<<<< HEAD const user = await resolveAppUser(session.githubId, session.githubLogin); +======= + const { data: user } = await supabaseAdmin + .from("users") + .select("id") + .eq("github_id", session.githubId) + .single(); + +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) if (!user) return Response.json({ error: "User not found" }, { status: 404 }); const today = todayStr(); +<<<<<<< HEAD // Prevent users from stockpiling unused freezes const { count } = await supabaseAdmin .from("streak_freezes") @@ -86,10 +127,14 @@ export async function POST() { ); } +======= + // only 1 unused freeze at a time +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) const { data: existing } = await supabaseAdmin .from("streak_freezes") .select("id") .eq("user_id", user.id) +<<<<<<< HEAD .eq("freeze_date", today) .maybeSingle(); @@ -99,6 +144,21 @@ export async function POST() { { user_id: user.id, freeze_date: today }, { onConflict: "user_id,freeze_date" } ) +======= + .gte("freeze_date", today) + .limit(1); + + if (Array.isArray(existing) && existing.length > 0) { + return Response.json( + { error: "You already have an unused streak freeze." }, + { status: 409 } + ); + } + + const { data: freeze, error } = await supabaseAdmin + .from("streak_freezes") + .insert({ user_id: user.id, freeze_date: today }) +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) .select() .single(); @@ -106,6 +166,7 @@ export async function POST() { return Response.json({ error: "Failed to apply freeze." }, { status: 500 }); } +<<<<<<< HEAD const alreadyExisted = existing !== null; const statusCode = alreadyExisted ? 200 : 201; @@ -137,3 +198,7 @@ export async function DELETE() { return Response.json({ success: true }); } +======= + return Response.json({ freeze }, { status: 201 }); +} +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) diff --git a/supabase/migrations/20260515000000_add_streak_freezes.sql b/supabase/migrations/20260515000000_add_streak_freezes.sql index b5db5b902..d49d2d92e 100644 --- a/supabase/migrations/20260515000000_add_streak_freezes.sql +++ b/supabase/migrations/20260515000000_add_streak_freezes.sql @@ -1,10 +1,15 @@ -- Migration: add streak_freezes table +<<<<<<< HEAD -- Allows users to protect one streak day (e.g. weekends, sick days) +======= +-- Allows users to protect one streak day +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) create table if not exists streak_freezes ( id text primary key default gen_random_uuid()::text, user_id text not null references users(id) on delete cascade, freeze_date date not null, +<<<<<<< HEAD created_at timestamptz default now() ); @@ -12,3 +17,10 @@ create index if not exists streak_freezes_user on streak_freezes(user_id); create unique index if not exists streak_freezes_user_date_uniq on streak_freezes(user_id, freeze_date); +======= + used_at timestamptz default now() +); + +create index if not exists streak_freezes_user on streak_freezes(user_id); +create index if not exists streak_freezes_user_date on streak_freezes(user_id, freeze_date); +>>>>>>> 1337d90 (feat: add streak freeze feature (#37)) From 28419c629ee193cc9f62237a17cde484c4e20a5a Mon Sep 17 00:00:00 2001 From: IshitaSingh0822 Date: Fri, 15 May 2026 13:32:04 +0530 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?unique=20constraint,=20created=5Fat,=20trailing=20newlines,=20r?= =?UTF-8?q?evert=20lockfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/metrics/streak/route.ts | 31 ++++++++++++++++++++++++++++- src/app/api/streak/freeze/route.ts | 22 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index fd64f5ed9..a0164233b 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -1,5 +1,4 @@ import { getServerSession } from "next-auth"; -import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { getAccountToken, getAllAccounts } from "@/lib/github-accounts"; import { GITHUB_API } from "@/lib/github"; @@ -25,10 +24,36 @@ async function fetchActiveDates( // stores each account's dates separately and merges them in the GET handler. const key = metricsCacheKey(cacheContext.userId, "streak", { githubLogin }); +<<<<<<< HEAD // withMetricsCache returns cached dates if available within the TTL window, // skipping all GitHub API calls below. This is the primary protection against // exhausting the Search API rate limit on repeated page loads. const dates = await withMetricsCache( +======= +function dateDiffDays(a: string, b: string): number { + return ( + (new Date(b).getTime() - new Date(a).getTime()) / (1000 * 60 * 60 * 24) + ); +} + +function toDateStr(d: Date): string { + return d.toISOString().slice(0, 10); +} + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.accessToken || !session.githubLogin || !session.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + // Fetch 90 days to calculate a meaningful longest streak + const since = new Date(); + since.setDate(since.getDate() - 90); + const sinceStr = since.toISOString().slice(0, 10); + + const searchRes = await fetch( + `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${sinceStr}&per_page=100&sort=author-date&order=asc`, +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) { bypass: cacheContext.bypass, key, @@ -176,6 +201,7 @@ function calculateStreakFromDates( // totalActiveDays counts only days with real commits or freezes in the 90-day window, // not the full streak length — useful for the "active days" stat on the dashboard. totalActiveDays: commitDays.length, +<<<<<<< HEAD freezeDates: Array.from(freezeDates), }; } @@ -352,4 +378,7 @@ export async function GET(req: NextRequest) { } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } +======= + }); +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) } diff --git a/src/app/api/streak/freeze/route.ts b/src/app/api/streak/freeze/route.ts index 735edab3f..5a85e8ccb 100644 --- a/src/app/api/streak/freeze/route.ts +++ b/src/app/api/streak/freeze/route.ts @@ -22,11 +22,15 @@ function todayStr(): string { return new Date().toISOString().slice(0, 10); } +<<<<<<< HEAD <<<<<<< HEAD // GET /api/streak/freeze // Returns whether the user currently has an unused freeze available. export async function GET(req: NextRequest) { ======= +======= +// GET /api/streak/freeze +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) // Returns whether the user currently has an unused freeze available. export async function GET() { >>>>>>> 1337d90 (feat: add streak freeze feature (#37)) @@ -88,7 +92,12 @@ async function getFreezeStatus(userId: string) { ======= return Response.json({ hasFreeze, freezeDate: hasFreeze ? pending![0].freeze_date : null }); } +<<<<<<< HEAD >>>>>>> 1337d90 (feat: add streak freeze feature (#37)) +======= + +// POST /api/streak/freeze +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) // Inserts a freeze for today. Fails if the user already holds an unused freeze. export async function POST() { const session = await getServerSession(authOptions); @@ -110,6 +119,7 @@ export async function POST() { const today = todayStr(); +<<<<<<< HEAD <<<<<<< HEAD // Prevent users from stockpiling unused freezes const { count } = await supabaseAdmin @@ -155,6 +165,8 @@ export async function POST() { ); } +======= +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) const { data: freeze, error } = await supabaseAdmin .from("streak_freezes") .insert({ user_id: user.id, freeze_date: today }) @@ -163,6 +175,13 @@ export async function POST() { .single(); if (error) { + // Unique constraint violation — already has a freeze for today + if (error.code === "23505") { + return Response.json( + { error: "You already have an unused streak freeze." }, + { status: 409 } + ); + } return Response.json({ error: "Failed to apply freeze." }, { status: 500 }); } @@ -201,4 +220,7 @@ export async function DELETE() { ======= return Response.json({ freeze }, { status: 201 }); } +<<<<<<< HEAD >>>>>>> 1337d90 (feat: add streak freeze feature (#37)) +======= +>>>>>>> 8c942cb (fix: address PR review — unique constraint, created_at, trailing newlines, revert lockfile) From e85078fc71b0e4f5bc4f115f04add7bc8752b423 Mon Sep 17 00:00:00 2001 From: IshitaSingh0822 Date: Fri, 15 May 2026 15:13:53 +0530 Subject: [PATCH 3/5] fix: revert package-lock.json --- package-lock.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/package-lock.json b/package-lock.json index a012c1542..3368a1ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4697,6 +4697,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz", "integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -5803,6 +5804,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6607,6 +6609,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -8163,6 +8166,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -8332,6 +8336,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -11808,6 +11813,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -13962,6 +13968,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -14132,6 +14139,7 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -14269,6 +14277,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -14281,6 +14290,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -16183,6 +16193,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16530,6 +16541,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" From be8f9050f920ddf3ebc02038cb280ce673f3ca2d Mon Sep 17 00:00:00 2001 From: IshitaSingh0822 Date: Sat, 30 May 2026 14:18:06 +0530 Subject: [PATCH 4/5] fix: add keyboard navigation and ARIA labels for accessibility (closes #1308) --- src/app/dashboard/page.tsx | 44 +++++++- src/app/dashboard/settings/page.tsx | 139 +++++++++++++++++++++--- src/app/globals.css | 34 ++++++ src/app/layout.tsx | 12 ++- src/app/page.tsx | 41 ++++++- src/components/ContributionGraph.tsx | 148 ++++++++++++++++++++++++- src/components/DashboardHeader.tsx | 34 +++++- src/components/GoalTracker.tsx | 154 ++++++++++++++++++++++++++- src/components/LanguageBreakdown.tsx | 59 ++++++++-- src/components/PRMetrics.tsx | 82 +++++++++++++- src/components/SignOutButton.tsx | 54 ++++++++++ 11 files changed, 765 insertions(+), 36 deletions(-) diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index aac71d790..8822a52f7 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -102,6 +102,7 @@ export default async function DashboardPage() { const session = await getServerSession(authOptions); if (!session) redirect("/"); +<<<<<<< HEAD return (
@@ -265,5 +266,46 @@ export default async function DashboardPage() {
+======= + if (!session) { + redirect("/"); + } + + return ( +
+ + +
+ {/* Live region: announces dynamic alerts to screen readers */} +
+ + + + {/* Row 1: Contribution graph + Streak */} +
+
+ +
+
+ +
+
+ + {/* Row 2: PR metrics */} +
+ +
+ + {/* Row 3: Top repos + Language breakdown + Goal tracker */} +
+ + + +
+
+
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) ); -} +} \ No newline at end of file diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 92b7511d1..bc90a7aac 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -1,6 +1,10 @@ "use client"; +<<<<<<< HEAD import { Suspense, useEffect, useMemo, useState } from "react"; +======= +import { useEffect, useRef, useState } from "react"; +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) import { useSession } from "next-auth/react"; import { redirect, useSearchParams } from "next/navigation"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; @@ -128,6 +132,7 @@ function SettingsPageContent() { const [accountsLoading, setAccountsLoading] = useState(true); const [saving, setSaving] = useState(false); const [copied, setCopied] = useState(false); +<<<<<<< HEAD const [removeError, setRemoveError] = useState(null); const [removingAccountId, setRemovingAccountId] = useState( null @@ -223,19 +228,18 @@ function SettingsPageContent() { setShowConfirmModal(false); setPendingPath(null); }; +======= + const statusRef = useRef(null); +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) - // Redirect to signin if not authenticated useEffect(() => { if (status === "unauthenticated") { redirect("/"); } }, [status]); - // Load settings on mount useEffect(() => { - if (status !== "authenticated" || !session?.githubLogin) { - return; - } + if (status !== "authenticated" || !session?.githubLogin) return; async function loadSettings() { try { @@ -369,9 +373,20 @@ function SettingsPageContent() { if (res.ok) { const updated = await res.json(); setSettings(updated); + if (statusRef.current) { + statusRef.current.textContent = value + ? "Public profile enabled. Your stats are now shareable." + : "Public profile disabled. Your stats are now private."; + } } else { console.error("Failed to update settings"); +<<<<<<< HEAD toast.error("Failed to update public profile setting"); +======= + if (statusRef.current) { + statusRef.current.textContent = "Failed to update settings. Please try again."; + } +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) } } catch (error) { console.error("Error updating settings:", error); @@ -541,6 +556,7 @@ function SettingsPageContent() { const copyShareLink = () => { if (!settings) return; const link = `${window.location.origin}/u/${settings.github_login}`; +<<<<<<< HEAD navigator.clipboard.writeText(link).then(() => { setCopied(true); toast.success("Link copied successfully!"); @@ -576,20 +592,29 @@ function SettingsPageContent() { } finally { setRemovingAccountId(null); } +======= + navigator.clipboard.writeText(link); + setCopied(true); + if (statusRef.current) { + statusRef.current.textContent = "Profile link copied to clipboard."; + } + setTimeout(() => setCopied(false), 2000); +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) }; if (status === "loading" || loading) { return (
-
+
{[1, 2, 3].map((i) => ( -
+
))}
@@ -602,7 +627,7 @@ function SettingsPageContent() { return (
-

+

Failed to load settings.

@@ -610,9 +635,21 @@ function SettingsPageContent() { ); } + const shareUrl = + typeof window !== "undefined" + ? `${window.location.origin}/u/${settings.github_login}` + : `/u/${settings.github_login}`; + return ( -
+
+ {/* Live region for status announcements */} +
+
+<<<<<<< HEAD
+======= +
+

Settings

+

+ Manage your profile and preferences +

+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
{statusMessage && ( @@ -647,17 +691,27 @@ function SettingsPageContent() { )} {/* Public Profile Section */} -
+
-

+

Public Profile

-

+

Share your GitHub stats with a public profile link

+<<<<<<< HEAD {/* Toggle Switch */} +======= + {/* Accessible Toggle Switch */} +
+ + {settings.is_public ? "On" : "Off"} + + +
+>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308))
{/* Share Link Section */} @@ -690,16 +778,25 @@ function SettingsPageContent() { Share Your Profile
+
)} +<<<<<<< HEAD {isDirty && (
@@ -1353,3 +1451,10 @@ export default function SettingsPage() { ); } +======= +
+
+
+ ); +} +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) diff --git a/src/app/globals.css b/src/app/globals.css index e85566853..9cb4190ee 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -22,12 +22,17 @@ --control: #f1f5f9; --control-hover: #e2e8f0; --tooltip: #ffffff; +<<<<<<< HEAD --tooltip-foreground: #111827; --destructive-muted: rgba(239, 68, 68, 0.1); --destructive-muted-border: rgba(239, 68, 68, 0.3); --destructive-foreground: #ffffff; --shadow-soft: 0 12px 30px -20px rgba(37, 99, 235, 0.35); --shadow-medium: 0 18px 35px -24px rgba(37, 99, 235, 0.4); +======= + --tooltip-foreground: #0f172a; + --focus-ring: #6366f1; +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) } .dark { @@ -56,6 +61,7 @@ --control-hover: #344a67; --tooltip: #1a2538; --tooltip-foreground: #f8fafc; +<<<<<<< HEAD --destructive-muted: rgba(248, 113, 113, 0.1); --destructive-muted-border: rgba(248, 113, 113, 0.3); --destructive-foreground: #ffffff; @@ -77,6 +83,9 @@ body:has(.lnd-root) { body:has(.wrapped-root) { background: #020817 !important; background-color: #020817 !important; +======= + --focus-ring: #818cf8; +>>>>>>> 393b334 (fix: add keyboard navigation and ARIA labels for accessibility (closes #1308)) } body { @@ -353,3 +362,28 @@ body { .animate-shimmer { animation: shimmer 1.5s infinite; } + +/* ── Global focus styles (WCAG 2.1 AA) ── */ +/* Visible focus ring for all interactive elements */ +:focus-visible { + outline: 3px solid var(--focus-ring); + outline-offset: 2px; + border-radius: 4px; +} + +/* Skip-to-content link – visually hidden until focused */ +.skip-link { + position: absolute; + top: -999px; + left: 0; + z-index: 9999; + padding: 0.5rem 1rem; + background: var(--accent); + color: var(--accent-foreground); + font-weight: 600; + border-radius: 0 0 4px 0; + text-decoration: none; +} +.skip-link:focus { + top: 0; +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2bc501382..6c9533d8c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -56,6 +56,7 @@ export default async function RootLayout({ children: React.ReactNode; }) { return ( +<<<<<<< HEAD