Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions src/components/layout/AuroraBackground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,12 @@ const variantBlobs: Record<AuroraVariant, BlobConfig> = {
* Reads `isAuroraEnabled` from ThemeContext for reactive toggling
* via Settings β†’ Appearance β€” no page reload needed.
*/

/** Extra pixels the canvas extends beyond the viewport on each side so the
* interactive glow trail isn't clipped at the screen edges.
* Half the maximum trail line width (90 px) is enough. */
const OVERSCAN = 45;

export function AuroraBackground({
variant = "default",
showGrid,
Expand All @@ -99,17 +105,24 @@ export function AuroraBackground({
const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(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] : [];

// 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.
const OVERSCAN = 30;
// 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]);

// OVERSCAN is module-scoped (see below the component) β€” not reactive,
// so it is not in the useEffect dependency array.

useEffect(() => {
if (!effectiveInteractive) return;
Expand All @@ -125,6 +138,17 @@ export function AuroraBackground({
let lastTime = performance.now();
let isRunning = false;

// Normalized progress across the 10–100 slider range (0.0 at 10%, 1.0 at 100%).
const sliderProgress = () => Math.max(0, Math.min(1, (glowIntensityRef.current - 10) / 90));

// Peak stroke width of the trail, in px.
// 6px at 10% (keeping the original fine cursor size), scaling up to 90px at 100% (expanded atmospheric aura).
const maxLineWidth = () => 6 + sliderProgress() * 84;

// Alpha multiplier.
// 0.28 at 10% (2x higher color saturation and density vs previous 0.12), scaling up to 0.70 at 100% (bright, radiant intensity).
const maxAlpha = () => 0.28 + sliderProgress() * 0.42;

const resize = () => {
canvas.width = window.innerWidth + OVERSCAN * 2;
canvas.height = window.innerHeight + OVERSCAN * 2;
Expand Down Expand Up @@ -169,8 +193,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();
}
}
Expand Down Expand Up @@ -206,7 +230,7 @@ export function AuroraBackground({
window.removeEventListener("pointermove", handlePointerMove);
if (rafId) cancelAnimationFrame(rafId);
};
}, [effectiveInteractive, OVERSCAN]);
}, [effectiveInteractive]);

return (
<motion.div
Expand Down
11 changes: 7 additions & 4 deletions src/components/layout/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,15 +208,18 @@ function SidebarContent({
.join("|")}`;

return (
<div className="flex h-full flex-col bg-app-bg text-app-text">
<div className="flex items-center gap-3 px-[24px] py-[24px]">
<div className="flex h-full min-h-0 flex-col bg-app-bg text-app-text">
<div className="flex shrink-0 items-center gap-3 px-[24px] py-[24px]">
<SidebarLogo />

<h1 className="text-lg leading-none font-bold tracking-tight text-app-text">SprintStart</h1>
</div>

<nav
aria-label={ariaLabel}
// Marks this element as the scroll container the nav rows re-measure
// against β€” see the scroll listener in `SidebarNavLink`.
data-sidebar-scroll="true"
// Tracked on the nav, not per entry: pointer enter/leave on the
// individual rows is skipped outright when the mouse crosses
// several of them inside one frame.
Expand All @@ -229,7 +232,7 @@ function SidebarContent({
// further left again would need a smaller scale to keep that
// gap. Header and footer share the inset, so everything lines
// up on one left edge.
className="flex-1 space-y-[5px] px-[24px] py-[20px]"
className="app-scrollbar min-h-0 flex-1 space-y-[5px] overflow-x-hidden overflow-y-auto px-[24px] py-[20px]"
>
{sections.map((section, sectionIndex) => (
<div
Expand Down Expand Up @@ -269,7 +272,7 @@ function SidebarContent({
{/* Floating glass card instead of a full-bleed bar. The 12px outer
gutter plus 12px inner padding lines its content up with the
24px inset used by the nav items above. */}
<div className="px-[12px] pt-[8px] pb-[16px]">
<div className="shrink-0 px-[12px] pt-[8px] pb-[16px]">
<div className="space-y-[12px] rounded-[18px] border border-app-border/70 bg-app-surface/70 p-[12px] shadow-[0_10px_30px_-18px_rgba(0,0,0,0.5)] backdrop-blur-xl">
{profile && (
<div className="flex items-center justify-between gap-2 py-[2px]">
Expand Down
32 changes: 30 additions & 2 deletions src/components/layout/SidebarNavLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export function SidebarNavLink({
const centerYRef = useRef(0);

useLayoutEffect(() => {
let frame = 0;

function measure() {
const element = elementRef.current;
if (!element) return;
Expand All @@ -153,10 +155,36 @@ export function SidebarNavLink({
centerYRef.current = rect.top + rect.height / 2;
}

// Scroll and resize both fire far faster than the layout they invalidate,
// and every row runs its own handler β€” so coalesce into one measurement
// per frame instead of one per event.
function scheduleMeasure() {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
measure();
});
}

measure();
window.addEventListener("resize", measure);
window.addEventListener("resize", scheduleMeasure);
// Bubble phase, so this only fires for the document scroller.
window.addEventListener("scroll", scheduleMeasure, { passive: true });

// The sidebar's own scroll container is the other thing that moves these
// rows. Listening on `window` in the *capture* phase would catch it, but
// would also re-measure every row on every scroll anywhere in the app β€”
// main content, drawers, tables β€” which is exactly the per-entry
// `getBoundingClientRect` cost `centerYRef` exists to avoid.
const scroller = elementRef.current?.closest("[data-sidebar-scroll]");
scroller?.addEventListener("scroll", scheduleMeasure, { passive: true });

return () => window.removeEventListener("resize", measure);
return () => {
if (frame) cancelAnimationFrame(frame);
window.removeEventListener("resize", scheduleMeasure);
window.removeEventListener("scroll", scheduleMeasure);
scroller?.removeEventListener("scroll", scheduleMeasure);
};
});

// Derived from the pointer rather than from `pointerenter` on this element.
Expand Down
15 changes: 15 additions & 0 deletions src/context/ThemeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ export type Theme = "light" | "dark" | "system";
*/
export type StyleMode = "ultra" | "classic";

/**
* Bounds and default for the cursor-glow intensity setting.
*
* Beside the context type rather than in `ThemeProvider`: the settings slider
* and the `useTheme` fallback both need the numbers, and neither should have to
* import the provider module to get them.
*/
export const GLOW_INTENSITY_MIN = 10;
export const GLOW_INTENSITY_MAX = 100;
export const GLOW_INTENSITY_DEFAULT = 50;

/**
* Shape of the theme context.
*/
Expand All @@ -34,6 +45,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). */
Expand Down
40 changes: 39 additions & 1 deletion src/context/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import type { ReactNode } from "react";
import { useEffect, useLayoutEffect, useState } from "react";
import type { StyleMode, Theme } from "./ThemeContext";
import { ThemeContext } from "./ThemeContext";
import {
GLOW_INTENSITY_DEFAULT,
GLOW_INTENSITY_MAX,
GLOW_INTENSITY_MIN,
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";

/**
Expand Down Expand Up @@ -130,6 +136,25 @@ function getInitialTiltEnabled(): boolean {
return false;
}

/**
* 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.
*
Expand All @@ -155,6 +180,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const [isAuroraEnabled, setIsAuroraEnabledState] = useState<boolean>(() =>
getInitialAuroraEnabled(),
);
const [glowIntensity, setGlowIntensityState] = useState<number>(() => getInitialGlowIntensity());
const [isTiltEnabled, setIsTiltEnabledState] = useState<boolean>(() => getInitialTiltEnabled());

// Sync before paint to avoid a FOUC of the default light palette.
Expand Down Expand Up @@ -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 {
Expand All @@ -262,6 +298,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
isClassicMode,
isAuroraEnabled,
setIsAuroraEnabled,
glowIntensity,
setGlowIntensity,
isTiltEnabled,
setIsTiltEnabled,
}}
Expand Down
4 changes: 3 additions & 1 deletion src/context/useTheme.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useContext } from "react";
import { ThemeContext, type ThemeContextType } from "./ThemeContext";
import { GLOW_INTENSITY_DEFAULT, ThemeContext, type ThemeContextType } from "./ThemeContext";

/**
* Hook to access the global theme context.
Expand All @@ -21,6 +21,8 @@ export function useTheme(): ThemeContextType {
isClassicMode: false,
isAuroraEnabled: false,
setIsAuroraEnabled: () => {},
glowIntensity: GLOW_INTENSITY_DEFAULT,
setGlowIntensity: () => {},
isTiltEnabled: false,
setIsTiltEnabled: () => {},
};
Expand Down
26 changes: 13 additions & 13 deletions src/features/knowledge-request/components/AnswerForm.tsx
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -27,6 +29,7 @@ export function AnswerForm({ originalQuestion, onSubmit, onCancel }: AnswerFormP
setStatus("saving");
try {
await onSubmit(answer.trim(), question.trim());
setStatus("idle");
} catch {
setStatus("error");
}
Expand Down Expand Up @@ -64,22 +67,19 @@ export function AnswerForm({ originalQuestion, onSubmit, onCancel }: AnswerFormP
<p className="text-xs text-app-danger-text">Couldn&apos;t save that β€” try again.</p>
)}
<div className="flex items-center gap-2">
<button
type="button"
<Button
variant="primary"
size="sm"
icon={<BookCheck className="h-3.5 w-3.5" />}
loading={status === "saving"}
disabled={!answer.trim()}
onClick={() => void submit()}
disabled={!answer.trim() || status === "saving"}
className="rounded-lg bg-app-brand px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-app-brand-hover disabled:cursor-not-allowed disabled:opacity-50"
>
{status === "saving" ? "Saving…" : "Answer & publish"}
</button>
<button
type="button"
onClick={onCancel}
disabled={status === "saving"}
className="rounded-lg px-3 py-1.5 text-sm text-app-text-muted transition-colors hover:bg-app-surface-hover hover:text-app-text disabled:opacity-50"
>
Answer &amp; publish
</Button>
<Button variant="secondary" size="sm" disabled={status === "saving"} onClick={onCancel}>
Cancel
</button>
</Button>
</div>
</div>
);
Expand Down
Loading
Loading