From 71503fc38341f71157a2ba6eb43cee346cc3a067 Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Tue, 25 Aug 2026 20:31:15 +0200 Subject: [PATCH 1/8] Let the cursor-glow's size and brightness follow a user setting The aurora background draws a light trail behind the pointer, and every number that shaped it was a magic constant inside the effect closure: the trail tapered from a fixed 60px line width, at a fixed peak alpha of 0.36. Nobody could turn that down short of disabling the whole effect, which was all-or-nothing for what is fundamentally a matter of taste and screen size. This adds a single persisted setting, glowIntensity, running 10-100: - ThemeContext declares it; ThemeProvider owns the state, clamps reads and writes into [10,100] (a hand-edited localStorage value must not be able to break the effect), and persists under the existing sprintstart:* key convention. Default is 50, so turning the effect on lands at a moderate glow rather than full blast - the effect is opt-in, and opting in should not mean maximum. - AuroraBackground scales both knobs from it: radius as 60 * t * life and alpha as life * 0.36 * (0.25 + 0.75 * t). The alpha floor keeps the lowest setting visible, because the radius shrink already does most of the dimming work - without the floor, 10% meant an invisible trail rather than a small one. - The value reaches the animation loop through a ref synced in a useEffect, not an effect dependency. The rAF loop then reads the latest value every frame, so dragging the slider changes the live trail immediately while the pointer listener stays mounted - wiring it as a dependency instead would tear the listener down and clear the trail mid-gesture on every step of the drag. --- src/components/layout/AuroraBackground.tsx | 21 ++++++++++-- src/context/ThemeContext.ts | 4 +++ src/context/ThemeProvider.tsx | 38 ++++++++++++++++++++++ src/context/useTheme.ts | 4 ++- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/components/layout/AuroraBackground.tsx b/src/components/layout/AuroraBackground.tsx index 5a573307..5b37e276 100644 --- a/src/components/layout/AuroraBackground.tsx +++ b/src/components/layout/AuroraBackground.tsx @@ -99,13 +99,22 @@ export function AuroraBackground({ const containerRef = useRef(null); const canvasRef = useRef(null); - const { isClassicMode: classic, isAuroraEnabled } = useTheme(); + const { isClassicMode: classic, isAuroraEnabled, glowIntensity } = useTheme(); const enabled = !classic && isAuroraEnabled; const effectiveInteractive = interactive && enabled; const effectiveGrid = (showGrid ?? variant !== "login") && enabled; const blobs = enabled ? variantBlobs[variant] : []; + // The glow's radius and opacity both scale with the user's intensity setting + // (Settings → Appearance). Held in a ref rather than an effect dependency so + // dragging the slider doesn't tear down the pointer listener mid-gesture — + // the running rAF loop simply reads the latest value each frame. + const glowIntensityRef = useRef(glowIntensity); + useEffect(() => { + glowIntensityRef.current = glowIntensity; + }, [glowIntensity]); + // How many extra pixels to extend the canvas beyond the viewport on each // side, so the interactive glow trail isn't clipped at the screen edges. // Half the maximum line width (60px) is enough. @@ -125,6 +134,12 @@ export function AuroraBackground({ let lastTime = performance.now(); let isRunning = false; + // Peak stroke width of the trail, in px, at the user's chosen intensity. + const maxLineWidth = () => 60 * (glowIntensityRef.current / 100); + // Alpha multiplier. A floor keeps the lowest setting visible — radius + // shrink alone already does most of the dimming work. + const maxAlpha = () => 0.36 * (0.25 + 0.75 * (glowIntensityRef.current / 100)); + const resize = () => { canvas.width = window.innerWidth + OVERSCAN * 2; canvas.height = window.innerHeight + OVERSCAN * 2; @@ -169,8 +184,8 @@ export function AuroraBackground({ ctx.beginPath(); ctx.moveTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); - ctx.strokeStyle = `rgba(${getColor(life)}, ${life * 0.36})`; - ctx.lineWidth = 60 * life; // tapers from 60px down to 0 + ctx.strokeStyle = `rgba(${getColor(life)}, ${life * maxAlpha()})`; + ctx.lineWidth = maxLineWidth() * life; // tapers from the peak width down to 0 ctx.stroke(); } } diff --git a/src/context/ThemeContext.ts b/src/context/ThemeContext.ts index 4c608fb1..97f770f5 100644 --- a/src/context/ThemeContext.ts +++ b/src/context/ThemeContext.ts @@ -34,6 +34,10 @@ export interface ThemeContextType { isAuroraEnabled: boolean; /** Enables or disables the aurora background effect (persisted to localStorage). */ setIsAuroraEnabled: (enabled: boolean) => void; + /** Cursor-glow intensity of the aurora spotlight, 10–100 (percent). */ + glowIntensity: number; + /** Sets the cursor-glow intensity, clamped to 10–100 and persisted to localStorage. */ + setGlowIntensity: (value: number) => void; /** Whether the 3D tilt / spotlight hover effect on cards is enabled. */ isTiltEnabled: boolean; /** Enables or disables the card tilt effect (persisted to localStorage). */ diff --git a/src/context/ThemeProvider.tsx b/src/context/ThemeProvider.tsx index aa5c8b3e..6ea7368a 100644 --- a/src/context/ThemeProvider.tsx +++ b/src/context/ThemeProvider.tsx @@ -6,6 +6,7 @@ import { ThemeContext } from "./ThemeContext"; const STORAGE_KEY = "theme"; const STYLE_STORAGE_KEY = "style-mode"; const AURORA_STORAGE_KEY = "sprintstart:aurora-enabled"; +const GLOW_INTENSITY_STORAGE_KEY = "sprintstart:glow-intensity"; const TILT_STORAGE_KEY = "sprintstart:tilt-enabled"; /** @@ -130,6 +131,30 @@ function getInitialTiltEnabled(): boolean { return false; } +/** Bounds and default for the cursor-glow intensity slider. */ +export const GLOW_INTENSITY_MIN = 10; +export const GLOW_INTENSITY_MAX = 100; +export const GLOW_INTENSITY_DEFAULT = 50; + +/** + * Reads the user's stored cursor-glow intensity, clamped into 10–100. + * Anything missing or unparseable falls back to the default — a hand-edited + * localStorage value must not be able to break the effect. + */ +function getInitialGlowIntensity(): number { + let stored: string | null = null; + try { + stored = window.localStorage.getItem(GLOW_INTENSITY_STORAGE_KEY); + } catch { + // localStorage unavailable. + } + const parsed = Number.parseInt(stored ?? "", 10); + if (Number.isNaN(parsed)) { + return GLOW_INTENSITY_DEFAULT; + } + return Math.min(GLOW_INTENSITY_MAX, Math.max(GLOW_INTENSITY_MIN, parsed)); +} + /** * Provider component that manages the application's visual theme. * @@ -155,6 +180,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) { const [isAuroraEnabled, setIsAuroraEnabledState] = useState(() => getInitialAuroraEnabled(), ); + const [glowIntensity, setGlowIntensityState] = useState(() => getInitialGlowIntensity()); const [isTiltEnabled, setIsTiltEnabledState] = useState(() => getInitialTiltEnabled()); // Sync before paint to avoid a FOUC of the default light palette. @@ -240,6 +266,16 @@ export function ThemeProvider({ children }: { children: ReactNode }) { } }; + const setGlowIntensity = (value: number) => { + const clamped = Math.min(GLOW_INTENSITY_MAX, Math.max(GLOW_INTENSITY_MIN, value)); + setGlowIntensityState(clamped); + try { + window.localStorage.setItem(GLOW_INTENSITY_STORAGE_KEY, String(clamped)); + } catch { + // localStorage unavailable. + } + }; + const setIsTiltEnabled = (enabled: boolean) => { setIsTiltEnabledState(enabled); try { @@ -262,6 +298,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) { isClassicMode, isAuroraEnabled, setIsAuroraEnabled, + glowIntensity, + setGlowIntensity, isTiltEnabled, setIsTiltEnabled, }} diff --git a/src/context/useTheme.ts b/src/context/useTheme.ts index 6866583c..7e92323a 100644 --- a/src/context/useTheme.ts +++ b/src/context/useTheme.ts @@ -20,7 +20,9 @@ export function useTheme(): ThemeContextType { toggleStyleMode: () => {}, isClassicMode: false, isAuroraEnabled: false, - setIsAuroraEnabled: () => {}, + setIsAuroraEnabled: () => undefined, + glowIntensity: 50, + setGlowIntensity: () => undefined, isTiltEnabled: false, setIsTiltEnabled: () => {}, }; From c4289254326d87bbef164c4c54070c3cfc7c3f17 Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Tue, 25 Aug 2026 20:31:22 +0200 Subject: [PATCH 2/8] Add the glow intensity slider under the aurora toggle in Settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intensity setting added in the previous commit had no surface. It lives in Settings > Appearance, directly under the Aurora Background toggle, and only appears while that toggle is on - a slider for a disabled effect would be noise, and hiding it keeps the opt-in pattern honest: enable first, then tune. The control itself is deliberately plain. This is the app's first range input, so it uses the native control tinted with accent-app-brand rather than hand-built track CSS; if a second slider ever arrives, that is the point to extract shared styling, not before. Steps run in increments of ten - ten discrete stops are easy to hit precisely, while a 91-value drag would make fine adjustment fiddly without making the result perceptibly different. The current value renders beside the label ("Glow intensity · 50") with tabular numbers so the readout does not wiggle while dragging. Changes preview live: the effect reads the context value every frame, so there is no apply or save step to forget. Tests cover the three behaviors that matter: the slider is absent while aurora is off, it appears at the default 50 once the toggle is flipped, and changing it persists to localStorage and updates the readout. --- .../settings/components/AppearanceSection.tsx | 99 +++++++++++++------ .../settings/AppearanceSection.test.tsx | 32 +++++- 2 files changed, 102 insertions(+), 29 deletions(-) diff --git a/src/features/settings/components/AppearanceSection.tsx b/src/features/settings/components/AppearanceSection.tsx index 9d70cecb..820125a2 100644 --- a/src/features/settings/components/AppearanceSection.tsx +++ b/src/features/settings/components/AppearanceSection.tsx @@ -1,4 +1,5 @@ import { Monitor, Moon, Pointer, Sparkles, Sun } from "lucide-react"; +import { GLOW_INTENSITY_MAX, GLOW_INTENSITY_MIN } from "../../../context/ThemeProvider"; import { useTheme } from "../../../context/useTheme"; import type { Theme } from "../../../context/ThemeContext"; @@ -17,8 +18,16 @@ const OPTIONS: ReadonlyArray<{ value: Theme; label: string; icon: typeof Sun }> * meaning never conveyed by colour alone). */ export function AppearanceSection() { - const { theme, setTheme, isAuroraEnabled, setIsAuroraEnabled, isTiltEnabled, setIsTiltEnabled } = - useTheme(); + const { + theme, + setTheme, + isAuroraEnabled, + setIsAuroraEnabled, + glowIntensity, + setGlowIntensity, + isTiltEnabled, + setIsTiltEnabled, + } = useTheme(); return (
@@ -51,36 +60,70 @@ export function AppearanceSection() { })}
-
-
- -
-
- Aurora Background -
-
- Animated ambient glow and cursor spotlight on page backgrounds. +
+
+
+ +
+
+ Aurora Background +
+
+ Animated ambient glow and cursor spotlight on page backgrounds. +
-
- + > + + +
+ + {isAuroraEnabled && ( +
+
+ + {/* tabular-nums so the number doesn't wiggle while dragging. */} + {glowIntensity}% +
+ setGlowIntensity(event.target.valueAsNumber)} + // Native control tinted with the brand colour — deliberately no + // custom track CSS until a second slider justifies extracting one. + className="mt-2 w-full cursor-pointer rounded-full accent-app-brand focus-visible:ring-2 focus-visible:ring-app-focus focus-visible:outline-none" + /> +

+ Size and brightness of the glow that follows your mouse. +

+
+ )}
diff --git a/tests/unit/features/settings/AppearanceSection.test.tsx b/tests/unit/features/settings/AppearanceSection.test.tsx index 981e9241..94d929db 100644 --- a/tests/unit/features/settings/AppearanceSection.test.tsx +++ b/tests/unit/features/settings/AppearanceSection.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router-dom"; import { ThemeProvider } from "../../../../src/context/ThemeProvider"; @@ -81,4 +81,34 @@ describe("AppearanceSection", () => { expect(window.localStorage.getItem("theme")).toBe("system"); expect(document.documentElement.classList.contains("dark")).toBe(true); }); + + it("hides the glow intensity slider while aurora is off", () => { + renderWithProviders(); + + expect(screen.queryByLabelText("Glow intensity")).not.toBeInTheDocument(); + }); + + it("shows the glow intensity slider at its default of 50 once aurora is on", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("switch", { name: "Aurora Background" })); + + const slider = screen.getByLabelText("Glow intensity"); + expect(slider).toHaveValue("50"); + expect(screen.getByText("50%")).toBeInTheDocument(); + }); + + it("persists a changed glow intensity, clamped into the 10–100 range", () => { + window.localStorage.setItem("sprintstart:aurora-enabled", "true"); + renderWithProviders(); + + const slider = screen.getByLabelText("Glow intensity"); + // Fire the change event directly: jsdom doesn't lay out range inputs, + // so pointer-drag simulation can't move the thumb. + fireEvent.change(slider, { target: { value: "70" } }); + + expect(window.localStorage.getItem("sprintstart:glow-intensity")).toBe("70"); + expect(screen.getByText("70%")).toBeInTheDocument(); + }); }); From 2589170e25797d35ca16fb87d4c3c1c83068765a Mon Sep 17 00:00:00 2001 From: Daniil Perkin Date: Tue, 25 Aug 2026 20:31:31 +0200 Subject: [PATCH 3/8] Bring the escalation inbox up to the shared design patterns The escalation inbox predated several of the app's shared UI primitives and kept its own hand-rolled versions after those primitives landed. This migrates it onto the real ones, pattern by pattern. Tabs. The page built its Open / Durable answers switcher from a local TabButton: an underline bar with a border-bottom active state. Every other page had moved to SegmentedTabs - the sliding brand pill with hover magnify - leaving this the one page whose section switcher looked and behaved differently. It now uses SegmentedTabs (with its own layoutId, since Framer matches those globally), and the panel content sits inside SlidingTabPanel like its siblings, so switching views slides directionally instead of flicking. Counts still stay hidden while their list loads, and the aria-label carries over. Empty states. The page defined a private EmptyState with a solid border and p-16 padding - one more of the hand-written variants the UI consistency work replaced. It now uses ui/EmptyState, so an empty inbox looks like every other empty state in the app: dashed border, shared spacing, body copy as children. The distinction between "no escalations" and "still loading" continues to come from the words. Loading. The View helper rendered a raw Loader2 with no accessible announcement - a screen reader user got silence while lists loaded. It now uses ui/Spinner with specific labels, which announces the wait through role=status once, app-wide. Buttons. RequestCard, AnswerForm and CanonicalAnswerCard spelled their action buttons by hand six times over: bg-app-brand plus hand-picked padding, hover, and disabled classes. Beyond the drift itself, those buttons had no focus-visible ring (invisible keyboard focus), no aria-busy while saving, and inconsistent disabled opacity. They are all ui/Button now - primary for the main action, secondary for cancel, ghost for dismiss - with loading states driven by the loading prop instead of string swaps like "Dismissing...". That deletes roughly forty lines of bespoke className and makes every action reachable, announced, and visually consistent. --- .../components/AnswerForm.tsx | 25 +- .../components/CanonicalAnswerCard.tsx | 25 +- .../components/KnowledgeRequestInboxPage.tsx | 230 ++++++++---------- .../components/RequestCard.tsx | 23 +- 4 files changed, 138 insertions(+), 165 deletions(-) diff --git a/src/features/knowledge-request/components/AnswerForm.tsx b/src/features/knowledge-request/components/AnswerForm.tsx index d78b6d3e..50123118 100644 --- a/src/features/knowledge-request/components/AnswerForm.tsx +++ b/src/features/knowledge-request/components/AnswerForm.tsx @@ -1,5 +1,7 @@ import { useId, useState } from "react"; +import { BookCheck } from "lucide-react"; import { AutoResizeTextarea } from "../../../components/ui/AutoResizeTextarea"; +import { Button } from "../../../components/ui/Button"; type AnswerFormProps = { /** The hire's original wording — pre-fills the (optional) generalised question. */ @@ -64,22 +66,19 @@ export function AnswerForm({ originalQuestion, onSubmit, onCancel }: AnswerFormP

Couldn't save that — try again.

)}
- - + +
); diff --git a/src/features/knowledge-request/components/CanonicalAnswerCard.tsx b/src/features/knowledge-request/components/CanonicalAnswerCard.tsx index 59a858a0..c64b3d1f 100644 --- a/src/features/knowledge-request/components/CanonicalAnswerCard.tsx +++ b/src/features/knowledge-request/components/CanonicalAnswerCard.tsx @@ -1,8 +1,9 @@ import { useId, useState } from "react"; import { BookCheck, Pencil } from "lucide-react"; +import { AutoResizeTextarea } from "../../../components/ui/AutoResizeTextarea"; +import { Button } from "../../../components/ui/Button"; import type { CanonicalAnswer } from "../types"; import { formatDateTime } from "../format"; -import { AutoResizeTextarea } from "../../../components/ui/AutoResizeTextarea"; type CanonicalAnswerCardProps = { answer: CanonicalAnswer; @@ -85,22 +86,18 @@ export function CanonicalAnswerCard({

Couldn't save that — try again.

)}
- - + +
) : ( diff --git a/src/features/knowledge-request/components/KnowledgeRequestInboxPage.tsx b/src/features/knowledge-request/components/KnowledgeRequestInboxPage.tsx index a8d6b769..efbb295e 100644 --- a/src/features/knowledge-request/components/KnowledgeRequestInboxPage.tsx +++ b/src/features/knowledge-request/components/KnowledgeRequestInboxPage.tsx @@ -1,6 +1,10 @@ import { useMemo, useState } from "react"; -import { AlertCircle, BookCheck, FolderKanban, Inbox, Loader2 } from "lucide-react"; +import { AlertCircle, BookCheck, FolderKanban, Inbox } from "lucide-react"; import { PageHeader } from "../../../components/layout/PageHeader"; +import { EmptyState } from "../../../components/ui/EmptyState"; +import { SegmentedTabs, type SegmentedTabOption } from "../../../components/ui/SegmentedTabs"; +import { SlidingTabPanel } from "../../../components/ui/SlidingTabPanel"; +import { Spinner } from "../../../components/ui/Spinner"; import { useAuth } from "../../../context/useAuth"; import { useFetch } from "../../../hooks/useFetch"; import { PermissionGroup } from "../../../services/types"; @@ -11,6 +15,8 @@ import { CanonicalAnswerCard } from "./CanonicalAnswerCard"; type Tab = "open" | "answered"; +const TAB_ORDER: Tab[] = ["open", "answered"]; + /** * The PM side of the buddy's growth loop: the escalation inbox. A hire flags a question the buddy * could not answer; here a PM answers it — minting a durable answer the buddy then serves — or @@ -92,6 +98,22 @@ export function KnowledgeRequestInboxPage() { const openCount = orderedOpen.length; const answeredCount = orderedAnswers.length; + // Counts stay undefined while their list is loading, so the pill doesn't flash a stale "0". + const TAB_OPTIONS: SegmentedTabOption[] = [ + { + value: "open", + label: "Open", + icon: