feat(ux): slow animations + audio player + styled slider + pricing nav — 7I.4b

This commit is contained in:
reloop
2026-04-12 14:21:35 +09:00
parent e3cdfd9881
commit d14fde3bb4
9 changed files with 678 additions and 59 deletions

View File

@@ -59,6 +59,9 @@ async function bootstrap() {
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
prefix: '/uploads/',
});
app.useStaticAssets(join(__dirname, '..', '..', 'data', 'kice'), {
prefix: '/data/kice/',
});
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({

View File

@@ -707,11 +707,13 @@ const HeroTitle = styled.h1`
${animateIn}
animation-delay: 0.2s;
margin: 0 0 1.5rem;
min-height: 2.2em;
min-height: 3em;
font-family: ${theme.font.display};
font-size: clamp(3.25rem, 9vw, 5.5rem);
line-height: 1.04;
letter-spacing: -0.04em;
overflow: visible;
word-break: keep-all;
@media (max-width: ${theme.breakpoint.tablet}) {
font-size: clamp(2.5rem, 12vw, 4rem);
@@ -719,10 +721,15 @@ const HeroTitle = styled.h1`
`;
const HeroGradient = styled.span`
display: block;
overflow: visible;
min-height: 3em;
background: linear-gradient(135deg, #a78bfa 0%, #818cf8 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
white-space: normal;
word-break: keep-all;
`;
const HeroSubtitle = styled.p`

View File

@@ -1,6 +1,7 @@
'use client';
import React, { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import styled, { css } from 'styled-components';
import AppShell from '@/components/layout/AppShell';
@@ -577,40 +578,18 @@ function ProfileBody() {
<SectionTitle>/</SectionTitle>
</SectionHeader>
<SliderGroup>
<SliderBlock>
<SliderTop>
<SliderLabel> </SliderLabel>
<SliderValue>{currentGradeDraft}</SliderValue>
</SliderTop>
<GradeSlider
min={1}
max={9}
step={1}
value={currentGradeDraft}
onChange={(event) => setCurrentGradeDraft(Number(event.target.value))}
onMouseUp={queueGradeSave}
onTouchEnd={queueGradeSave}
onKeyUp={queueGradeSave}
$value={currentGradeDraft}
/>
</SliderBlock>
<SliderBlock>
<SliderTop>
<SliderLabel> </SliderLabel>
<SliderValue>{targetGradeDraft}</SliderValue>
</SliderTop>
<GradeSlider
min={1}
max={9}
step={1}
value={targetGradeDraft}
onChange={(event) => setTargetGradeDraft(Number(event.target.value))}
onMouseUp={queueGradeSave}
onTouchEnd={queueGradeSave}
onKeyUp={queueGradeSave}
$value={targetGradeDraft}
/>
</SliderBlock>
<GradeSliderField
label="현재 등급"
value={currentGradeDraft}
onChange={(value) => setCurrentGradeDraft(value)}
onCommit={queueGradeSave}
/>
<GradeSliderField
label="목표 등급"
value={targetGradeDraft}
onChange={(value) => setTargetGradeDraft(value)}
onCommit={queueGradeSave}
/>
</SliderGroup>
</CardSection>
</Grid>
@@ -739,6 +718,27 @@ function ProfileBody() {
<SectionTitle></SectionTitle>
</SectionHeader>
<AccountActionList>
<PlanManagementCard>
<PlanManagementBody>
<PlanManagementMeta>
<PlanManagementLabel>
<Icon name="crown" size={18} />
</PlanManagementLabel>
<PlanManagementTitle> </PlanManagementTitle>
<PlanManagementDesc>
`/pricing`
.
</PlanManagementDesc>
</PlanManagementMeta>
<PlanManagementActions>
<PlanTierBadge>{planLabel(user.subscriptionTier)}</PlanTierBadge>
<Button as={Link} href="/pricing" $variant="secondary">
</Button>
</PlanManagementActions>
</PlanManagementBody>
</PlanManagementCard>
<AccountAction type="button" onClick={handleExportData}>
<ActionLeft>
<Icon name="export" size={18} />
@@ -824,6 +824,56 @@ function planLabel(tier: MeUser['subscriptionTier']) {
return 'Free Plan';
}
interface GradeSliderFieldProps {
label: string;
value: number;
onChange: (value: number) => void;
onCommit: () => void;
}
function GradeSliderField({
label,
value,
onChange,
onCommit,
}: GradeSliderFieldProps) {
const percent = ((value - 1) / 8) * 100;
return (
<SliderBlock>
<SliderTop>
<SliderLabel>{label}</SliderLabel>
<SliderValue>{value}</SliderValue>
</SliderTop>
<SliderVisual>
<SliderBubble style={{ left: `calc(${percent}% + ${(0.5 - percent / 100) * 22}px)` }}>
{value}
</SliderBubble>
<GradeSlider
min={1}
max={9}
step={1}
value={value}
aria-label={label}
onChange={(event) => onChange(Number(event.target.value))}
onMouseUp={onCommit}
onTouchEnd={onCommit}
onKeyUp={onCommit}
$value={value}
/>
<TickRow aria-hidden="true">
{GRADE_OPTIONS.map((grade) => (
<TickItem key={grade}>
<TickMark $active={grade <= value} />
<TickLabel>{grade}</TickLabel>
</TickItem>
))}
</TickRow>
</SliderVisual>
</SliderBlock>
);
}
const surface = css`
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02)),
@@ -1088,6 +1138,8 @@ const SliderGroup = styled.div`
gap: 18px;
`;
const GRADE_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
const SliderBlock = styled.div`
padding: 16px;
border-radius: 18px;
@@ -1112,8 +1164,129 @@ const SliderValue = styled.div`
font-weight: 700;
`;
const SliderVisual = styled.div`
position: relative;
padding-top: 34px;
`;
const SliderBubble = styled.div`
position: absolute;
top: 0;
transform: translateX(-50%);
min-width: 64px;
padding: 6px 10px;
border-radius: 999px;
background: ${theme.color.brandGradient};
color: #ffffff;
font-size: 12px;
font-weight: 700;
text-align: center;
box-shadow: ${theme.shadow.glowIndigo};
pointer-events: none;
`;
const GradeSlider = styled.input<{ $value: number }>`
width: 100%;
height: 28px;
margin: 0;
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
--slider-fill: ${({ $value }) => (($value - 1) / 8) * 100}%;
&:focus-visible {
outline: none;
}
&::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(
90deg,
#4f46e5 0%,
#7c3aed var(--slider-fill),
${theme.color.surfaceHover} var(--slider-fill),
${theme.color.surfaceHover} 100%
);
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
margin-top: -8px;
border-radius: 50%;
border: 3px solid #ffffff;
background: ${theme.color.brandIndigo};
box-shadow: ${theme.shadow.glowIndigo};
}
&::-moz-range-track {
height: 4px;
border-radius: 999px;
background: ${theme.color.surfaceHover};
}
&::-moz-range-progress {
height: 4px;
border-radius: 999px;
background: ${theme.color.brandGradient};
}
&::-moz-range-thumb {
width: 20px;
height: 20px;
border: 3px solid #ffffff;
border-radius: 50%;
background: ${theme.color.brandIndigo};
box-shadow: ${theme.shadow.glowIndigo};
}
@media (max-width: ${theme.breakpoint.mobile}) {
height: 36px;
&::-webkit-slider-thumb {
width: 28px;
height: 28px;
margin-top: -12px;
}
&::-moz-range-thumb {
width: 28px;
height: 28px;
}
}
`;
const TickRow = styled.div`
display: grid;
grid-template-columns: repeat(9, minmax(0, 1fr));
gap: 4px;
margin-top: 14px;
`;
const TickItem = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
`;
const TickMark = styled.span<{ $active: boolean }>`
width: 2px;
height: 8px;
border-radius: 999px;
background: ${({ $active }) =>
$active ? theme.color.brandIndigo : theme.color.borderBrightAlpha};
`;
const TickLabel = styled.span`
color: ${theme.color.textMute};
font-size: 11px;
font-family: ${theme.font.mono};
`;
const PersonaGrid = styled.div`
@@ -1293,6 +1466,74 @@ const AccountActionList = styled.div`
gap: 12px;
`;
const PlanManagementCard = styled.div`
padding: 18px;
border-radius: 20px;
border: 1px solid rgba(129, 140, 248, 0.2);
background:
radial-gradient(circle at top right, rgba(124, 58, 237, 0.2), transparent 42%),
rgba(79, 70, 229, 0.08);
`;
const PlanManagementBody = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
@media (max-width: ${theme.breakpoint.tablet}) {
flex-direction: column;
align-items: flex-start;
}
`;
const PlanManagementMeta = styled.div`
display: grid;
gap: 8px;
`;
const PlanManagementLabel = styled.div`
display: inline-flex;
align-items: center;
gap: 8px;
color: #c7d2fe;
font-size: 13px;
font-weight: 700;
`;
const PlanManagementTitle = styled.h3`
margin: 0;
color: ${theme.color.textBright};
font-size: 18px;
`;
const PlanManagementDesc = styled.p`
margin: 0;
color: ${theme.color.textSub};
font-size: 14px;
line-height: 1.6;
`;
const PlanManagementActions = styled.div`
display: inline-flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
`;
const PlanTierBadge = styled.span`
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(11, 16, 32, 0.35);
color: ${theme.color.textBright};
font-size: 13px;
font-weight: 700;
`;
const AccountAction = styled.button`
display: flex;
align-items: center;

View File

@@ -13,6 +13,7 @@ import {
} from '@/components/exam/ProblemVisual';
import { Icon } from '@/components/ui/Icon';
import { ConfirmDialog } from '@/components/ui/Modal';
import Select from '@/components/ui/Select';
import { useToast } from '@/components/ui/Toast';
import {
getProblemSet,
@@ -36,6 +37,7 @@ const DEFAULT_PANEL_WIDTH = 560;
const PANEL_WIDTH_MIN = 320;
const PANEL_WIDTH_MAX = 900;
const TABLET_BREAKPOINT = 768;
const ENGLISH_LISTENING_PROBLEM_MAX = 17;
export default function ExamPage() {
const params = useParams<{ problemSetId: string }>();
@@ -63,6 +65,10 @@ export default function ExamPage() {
const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH);
const [isDraggingPanel, setIsDraggingPanel] = useState(false);
const [passageCollapsed, setPassageCollapsed] = useState(false);
const [selectedTrackIndex, setSelectedTrackIndex] = useState(0);
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
const [audioDuration, setAudioDuration] = useState(0);
const [audioPlaying, setAudioPlaying] = useState(false);
const examStartedAtRef = useRef<number>(0);
const problemEnteredAtRef = useRef<number>(0);
@@ -71,6 +77,8 @@ export default function ExamPage() {
const pageRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const dragPointerIdRef = useRef<number | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioShouldResumeRef = useRef(false);
const problems = problemSet?.problems ?? [];
const currentProblem = problems[currentIndex] ?? null;
@@ -100,6 +108,9 @@ export default function ExamPage() {
(passage && currentPassageImageUrl) || currentProblemImageUrl,
);
const currentProblemIsShortAnswer = isShortAnswerProblem(currentProblem);
const audioTracks = useMemo(() => buildAudioTracks(problemSet?.audioUrls), [problemSet]);
const selectedTrack = audioTracks[selectedTrackIndex] ?? null;
const hasAudioTracks = audioTracks.length > 0;
const loadProblemSet = async () => {
if (!Number.isFinite(problemSetId)) {
@@ -133,6 +144,10 @@ export default function ExamPage() {
setJumpOpen(false);
setProblemModalOpen(false);
setPassageCollapsed(false);
setSelectedTrackIndex(0);
setAudioCurrentTime(0);
setAudioDuration(0);
setAudioPlaying(false);
} catch {
setError('문제집을 불러오지 못했어. 네트워크 상태를 확인해줘.');
} finally {
@@ -188,6 +203,80 @@ export default function ExamPage() {
window.localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(clampPanelWidth(panelWidth)));
}, [panelWidth, isMobile]);
useEffect(() => {
if (!audioTracks.length) {
setSelectedTrackIndex(0);
return;
}
setSelectedTrackIndex((current) =>
current >= 0 && current < audioTracks.length ? current : 0,
);
}, [audioTracks]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
const onTimeUpdate = () => setAudioCurrentTime(audio.currentTime);
const onLoadedMetadata = () => {
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
setAudioCurrentTime(audio.currentTime);
};
const onDurationChange = () => {
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
};
const onPlay = () => setAudioPlaying(true);
const onPause = () => setAudioPlaying(false);
const onEnded = () => setAudioPlaying(false);
audio.addEventListener('timeupdate', onTimeUpdate);
audio.addEventListener('loadedmetadata', onLoadedMetadata);
audio.addEventListener('durationchange', onDurationChange);
audio.addEventListener('play', onPlay);
audio.addEventListener('pause', onPause);
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
audio.removeEventListener('durationchange', onDurationChange);
audio.removeEventListener('play', onPlay);
audio.removeEventListener('pause', onPause);
audio.removeEventListener('ended', onEnded);
};
}, [selectedTrackIndex]);
useEffect(() => {
const audio = audioRef.current;
if (!audio || !selectedTrack) return;
const shouldResume = audioShouldResumeRef.current && !audio.paused;
audio.pause();
audio.load();
setAudioCurrentTime(0);
setAudioDuration(0);
setAudioPlaying(false);
if (shouldResume) {
void audio.play().catch(() => {
audioShouldResumeRef.current = false;
});
}
}, [selectedTrack]);
useEffect(() => {
if (!currentProblem || problemSet?.subjectName !== '영어') return;
if (currentProblem.number < 1 || currentProblem.number > ENGLISH_LISTENING_PROBLEM_MAX) return;
const matchedIndex = audioTracks.findIndex(
(track) => track.problemNumber === currentProblem.number,
);
if (matchedIndex >= 0) {
setSelectedTrackIndex(matchedIndex);
}
}, [audioTracks, currentProblem, problemSet?.subjectName]);
useEffect(() => {
if (!problemSet || submitting || submittedRef.current) return;
@@ -417,6 +506,34 @@ export default function ExamPage() {
setIsDraggingPanel(true);
};
const toggleAudioPlayback = async () => {
const audio = audioRef.current;
if (!audio || !selectedTrack) return;
if (audio.paused) {
audioShouldResumeRef.current = true;
try {
await audio.play();
} catch {
audioShouldResumeRef.current = false;
showToast({ message: '음원을 재생하지 못했어. 파일 경로를 확인해줘.', variant: 'warning' });
}
return;
}
audioShouldResumeRef.current = false;
audio.pause();
};
const handleAudioProgressChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const audio = audioRef.current;
if (!audio) return;
const nextTime = Number(event.target.value);
audio.currentTime = nextTime;
setAudioCurrentTime(nextTime);
};
if (loading) {
const loadingState = (
<StateScreen $embedded={!focusMode}>
@@ -516,6 +633,63 @@ export default function ExamPage() {
</TopBarActions>
</TopBar>
{hasAudioTracks ? (
<AudioBarSection>
<AudioPlayerCard>
<AudioPlayerMain>
<PlayButton
type="button"
onClick={() => void toggleAudioPlayback()}
aria-label={audioPlaying ? '음원 일시정지' : '음원 재생'}
>
<Icon
name={audioPlaying ? 'pause' : 'caret-right'}
size={audioPlaying ? 22 : 24}
weight="fill"
color="#ffffff"
/>
</PlayButton>
<AudioMeta>
<AudioLabelRow>
<AudioTitle> </AudioTitle>
<AudioTime>
{formatAudioClock(audioCurrentTime)} / {formatAudioClock(audioDuration)}
</AudioTime>
</AudioLabelRow>
<AudioProgress
type="range"
min={0}
max={audioDuration > 0 ? audioDuration : 0}
step={0.1}
value={audioCurrentTime}
onChange={handleAudioProgressChange}
aria-label="음원 진행도"
$progress={audioDuration > 0 ? (audioCurrentTime / audioDuration) * 100 : 0}
/>
</AudioMeta>
<TrackSelectWrap>
<TrackSelect
value={selectedTrack?.index ?? null}
onChange={(value) => setSelectedTrackIndex(value as number)}
options={audioTracks.map((track) => ({
label: track.label,
value: track.index,
}))}
placeholder="음원 선택"
aria-label="듣기 음원 선택"
/>
</TrackSelectWrap>
</AudioPlayerMain>
<AudioElement ref={audioRef} preload="metadata">
{selectedTrack ? <source src={selectedTrack.url} type="audio/mpeg" /> : null}
</AudioElement>
</AudioPlayerCard>
</AudioBarSection>
) : null}
<Content>
<Workspace>
<ProblemPanel
@@ -798,6 +972,50 @@ function clampPanelWidth(width: number): number {
return Math.min(PANEL_WIDTH_MAX, Math.max(PANEL_WIDTH_MIN, Math.round(width)));
}
function buildAudioTracks(audioUrls: string[] | null | undefined) {
if (!Array.isArray(audioUrls)) return [];
return audioUrls
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value, index) => {
const normalized = value.trim().replace(/^\/+/, '');
const filename = normalized.split('/').pop() ?? normalized;
const filenameBase = filename.replace(/\.mp3$/i, '');
const problemNumber = extractLeadingNumber(filenameBase);
const label = filenameBase.replace(/^\d+[_-]?\s*/, '').trim() || filenameBase;
return {
index,
problemNumber,
label,
url: resolveUploadUrl(`/data/kice/${encodePathSegments(normalized)}`) ?? '',
};
})
.filter((track) => track.url.length > 0);
}
function encodePathSegments(path: string): string {
return path
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
}
function extractLeadingNumber(value: string): number | null {
const match = value.match(/^(\d{1,2})/);
if (!match) return null;
const parsed = Number.parseInt(match[1], 10);
return Number.isFinite(parsed) ? parsed : null;
}
function formatAudioClock(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return '00:00';
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.floor(totalSeconds % 60);
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
const Page = styled.div<{ $focusMode: boolean }>`
min-height: 100vh;
background: ${({ $focusMode }) =>
@@ -977,6 +1195,149 @@ const Content = styled.div`
}
`;
const AudioBarSection = styled.div`
max-width: 1440px;
margin: 0 auto;
padding: 18px 24px 0;
@media (max-width: ${theme.breakpoint.tablet}) {
padding: 16px 16px 0;
}
`;
const AudioPlayerCard = styled.section`
background: ${theme.color.surfaceDeep};
border: 1px solid ${theme.color.borderSoftAlpha};
border-radius: 20px;
box-shadow: ${theme.shadow.cardElevated};
padding: 16px 18px;
`;
const AudioPlayerMain = styled.div`
display: grid;
grid-template-columns: 48px minmax(0, 1fr) minmax(220px, 280px);
align-items: center;
gap: 16px;
@media (max-width: ${theme.breakpoint.tablet}) {
grid-template-columns: 48px minmax(0, 1fr);
}
@media (max-width: ${theme.breakpoint.mobile}) {
grid-template-columns: 1fr;
}
`;
const PlayButton = styled.button`
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: ${theme.color.brandIndigo};
box-shadow: ${theme.shadow.glowIndigo};
`;
const AudioMeta = styled.div`
min-width: 0;
display: grid;
gap: 10px;
`;
const AudioLabelRow = styled.div`
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
`;
const AudioTitle = styled.div`
color: ${theme.color.textBright};
font-size: 16px;
font-weight: 800;
`;
const AudioTime = styled.div`
color: ${theme.color.textSub};
font-size: 13px;
font-family: ${theme.font.mono};
`;
const AudioProgress = styled.input<{ $progress: number }>`
width: 100%;
height: 18px;
margin: 0;
-webkit-appearance: none;
appearance: none;
background: transparent;
cursor: pointer;
--progress: ${({ $progress }) => `${Math.max(0, Math.min(100, $progress))}%`};
&::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(
90deg,
#4f46e5 0%,
#7c3aed var(--progress),
rgba(255, 255, 255, 0.12) var(--progress),
rgba(255, 255, 255, 0.12) 100%
);
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -5px;
border-radius: 50%;
border: 2px solid #ffffff;
background: ${theme.color.brandIndigo};
box-shadow: ${theme.shadow.glowIndigo};
}
&::-moz-range-track {
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
&::-moz-range-progress {
height: 4px;
border-radius: 999px;
background: ${theme.color.brandGradient};
}
&::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid #ffffff;
background: ${theme.color.brandIndigo};
box-shadow: ${theme.shadow.glowIndigo};
}
`;
const TrackSelectWrap = styled.div`
width: 100%;
@media (max-width: ${theme.breakpoint.tablet}) {
grid-column: 1 / -1;
}
`;
const TrackSelect = styled(Select)`
width: 100%;
`;
const AudioElement = styled.audio`
display: none;
`;
const Workspace = styled.main`
display: grid;
grid-template-columns: minmax(320px, auto) 4px minmax(0, 1fr);

View File

@@ -54,6 +54,12 @@ const NAV_ITEMS: Array<{
icon: 'folders',
match: (pathname) => pathname.startsWith('/subjects'),
},
{
href: '/pricing',
label: '플랜',
icon: 'crown',
match: (pathname) => pathname.startsWith('/pricing'),
},
];
const SUBSCRIPTION_LABELS: Record<SubscriptionTier, string> = {
@@ -246,8 +252,8 @@ const LogoMark = styled.div`
background: ${theme.color.brandGradient};
box-shadow: ${theme.shadow.glowIndigo};
transition:
box-shadow 0.2s ease,
transform 0.2s ease;
box-shadow 0.25s ease,
transform 0.25s ease;
${Brand}:hover & {
box-shadow: ${theme.shadow.glowIndigoStrong};
@@ -284,9 +290,9 @@ const NavItem = styled(Link)<{ $active: boolean }>`
font-size: 14px;
font-weight: 500;
transition:
color 0.15s ease,
background-color 0.15s ease,
border-color 0.15s ease;
color 0.25s ease,
background-color 0.25s ease,
border-color 0.25s ease;
&:hover {
background: rgba(255, 255, 255, 0.05);
@@ -307,7 +313,7 @@ const NavIconWrap = styled.span<{ $active: boolean }>`
justify-content: center;
color: ${({ $active }) =>
$active ? theme.color.brandIndigo : 'rgba(255, 255, 255, 0.5)'};
transition: color 0.15s ease;
transition: color 0.25s ease;
${NavItem}:hover & {
color: ${({ $active }) =>
@@ -345,7 +351,7 @@ const UserButton = styled.button`
text-align: left;
color: ${theme.color.textBright};
cursor: pointer;
transition: background-color 0.15s ease;
transition: background-color 0.25s ease;
&:hover {
background: rgba(255, 255, 255, 0.05);
@@ -406,8 +412,8 @@ const CaretWrap = styled.span<{ $open: boolean }>`
justify-content: center;
color: rgba(255, 255, 255, 0.38);
transition:
opacity 0.15s ease,
color 0.15s ease;
opacity 0.25s ease,
color 0.25s ease;
opacity: ${({ $open }) => ($open ? 1 : 0.72)};
${UserButton}:hover & {
@@ -441,8 +447,8 @@ const DropdownLink = styled(Link)`
font-size: 13px;
font-weight: 500;
transition:
background-color 0.15s ease,
color 0.15s ease;
background-color 0.25s ease,
color 0.25s ease;
&:hover {
background: rgba(255, 255, 255, 0.06);
@@ -464,8 +470,8 @@ const LogoutButton = styled.button`
text-align: left;
cursor: pointer;
transition:
background-color 0.15s ease,
color 0.15s ease;
background-color 0.25s ease,
color 0.25s ease;
&:hover {
background: rgba(239, 68, 68, 0.08);

View File

@@ -10,7 +10,7 @@ import {
Users, ChalkboardTeacher, Sliders, FilePdf, MoonStars, Devices, Keyboard, Brain, Funnel,
MagnifyingGlass, DownloadSimple, CalendarBlank, CalendarCheck, BellRinging, Bell, Sparkle,
Minus, DotsThree, DotsThreeVertical, Translate, BookOpenText, SkipForward, Info, SignOut, Camera, Crown,
Export, Gear, ArrowRight, type IconProps as PhIconProps, type IconWeight,
Export, Gear, ArrowRight, Pause, type IconProps as PhIconProps, type IconWeight,
Scales, Feather, Clock, Flag, CheckSquare, Circle, ArrowLeft, User, Books,
} from '@phosphor-icons/react';
@@ -74,6 +74,7 @@ const ICON_MAP = {
'export': Export,
'gear': Gear,
'arrow-right': ArrowRight,
'pause': Pause,
'arrow-left': ArrowLeft,
'scales': Scales,
'feather': Feather,

View File

@@ -53,7 +53,7 @@ export default function ScrollReveal({
setRevealed(true);
observer.disconnect();
},
{ rootMargin: '0px 0px -80px 0px' },
{ rootMargin: '0px 0px -60px 0px' },
);
observer.observe(node);
@@ -86,7 +86,7 @@ const RevealRoot = styled.div<{
$revealed || $reducedMotion ? 'translateY(0)' : 'translateY(20px)'};
animation: ${({ $revealed, $reducedMotion }) =>
$revealed && !$reducedMotion
? css`${animations.fadeInUp} 0.7s cubic-bezier(0.16, 1, 0.3, 1) forwards`
? css`${animations.fadeInUp} 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards`
: 'none'};
animation-delay: ${({ $delay }) => `${$delay}ms`};

View File

@@ -13,9 +13,9 @@ interface TypingEffectProps {
export default function TypingEffect({
phrases,
typingSpeedMs = 55,
holdMs = 2200,
deletingSpeedMs = 30,
typingSpeedMs = 90,
holdMs = 3500,
deletingSpeedMs = 45,
}: TypingEffectProps) {
const [phraseIndex, setPhraseIndex] = useState(0);
const [displayText, setDisplayText] = useState('');
@@ -119,17 +119,17 @@ export default function TypingEffect({
}
const Root = styled.span`
display: inline-flex;
align-items: baseline;
gap: 0.08em;
display: inline;
min-height: 1.2em;
white-space: normal;
`;
const Caret = styled.span<{ $visible: boolean }>`
display: inline-block;
color: ${theme.color.brandViolet};
font-weight: 500;
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
transition: opacity 120ms linear;
transition: opacity 180ms linear;
@media (prefers-reduced-motion: reduce) {
animation: none;

View File

@@ -17,7 +17,7 @@ export const Card = styled.div<{
${({ $hoverable, $variant }) =>
($hoverable || $variant === 'hoverable') &&
css`
transition: border-color 0.15s ease, transform 0.15s ease;
transition: border-color 0.25s ease, transform 0.25s ease;
cursor: pointer;
&:hover {
border-color: ${theme.color.accent};