Skip to content

Commit 07674e6

Browse files
committed
feat: Enhance sidebar navigation with featured item styling and ordering
This change visually distinguishes "featured" navigation items like Subscription and TurboEngine with dynamic styling and hover effects, aiming to improve their prominence and user engagement. It also standardizes the sidebar's item order, ensuring featured items are grouped centrally and settings remain at the end. Minor UI refinements, such as removing the 'test build' footer text and simplifying 'Listen Host Preset' options, are included. The `ScrollArea` component is made more customizable, and a dedicated test verifies the new navigation structure and styling.
1 parent beb400a commit 07674e6

29 files changed

Lines changed: 203 additions & 89 deletions

src/renderer/components/SidebarNavigation.about.render.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('sidebar about section integration', () => {
2121
const source = await fs.readFile(sidebarPath, 'utf8');
2222

2323
assert.match(source, /renderAboutSectionTitle\(section\.id\)/);
24-
assert.match(source, /<ScrollArea className="h-full" type="always">/);
24+
assert.match(source, /<ScrollArea\s+className="h-full"\s+type="scroll"\s+scrollBarClassName="w-1\.5 p-0\.5"\s+scrollThumbClassName="bg-border\/65"/);
2525
assert.match(source, /grid grid-cols-4 gap-2/);
2626
assert.match(source, /https:\/\/www\.google\.com\/s2\/favicons/);
2727
assert.match(source, /<PopoverContent className="w-64 space-y-3" align="start">/);
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import assert from 'node:assert/strict';
2+
import fs from 'node:fs/promises';
3+
import path from 'node:path';
4+
import { describe, it } from 'node:test';
5+
6+
const sidebarPath = path.resolve(process.cwd(), 'src/renderer/components/SidebarNavigation.tsx');
7+
8+
describe('sidebar navigation ordering and featured styling', () => {
9+
it('keeps settings last and gives sponsor entries dedicated featured treatment', async () => {
10+
const source = await fs.readFile(sidebarPath, 'utf8');
11+
12+
assert.match(source, /const settingsNavigationItem: NavigationItem = \{/);
13+
assert.match(source, /const featuredNavigationItems: NavigationItem\[] = \[\s*subscriptionNavigationItem,\s*turboEngineNavigationItem,\s*\];/s);
14+
assert.match(source, /return \[\.\.\.baseItems, \.\.\.featuredNavigationItems, settingsNavigationItem\];/);
15+
assert.match(source, /whileHover=\{isFeatured \? \{ x: 0, y: -2 \} : \{ x: 0 \}\}/);
16+
assert.match(source, /getFeaturedNavigationPalette\(item\.emphasis, isDarkTheme\)/);
17+
});
18+
});

src/renderer/components/SidebarNavigation.subscription.render.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ describe('subscription sidebar and shell wiring', () => {
2424
assert.match(sidebarSource, /const subscriptionNavigationItem: NavigationItem = \{/);
2525
assert.match(sidebarSource, /id: 'subscription'/);
2626
assert.match(sidebarSource, /labelKey: 'sidebar\.subscription'/);
27-
assert.match(sidebarSource, /return \[\.\.\.baseItems, subscriptionNavigationItem, turboEngineNavigationItem\];/);
27+
assert.match(sidebarSource, /emphasis: 'sponsor'/);
28+
assert.match(sidebarSource, /return \[\.\.\.baseItems, \.\.\.featuredNavigationItems, settingsNavigationItem\];/);
2829
assert.doesNotMatch(sidebarSource, /distributionState\.winStoreMode/);
2930
assert.doesNotMatch(sidebarSource, /typeof window\.electronAPI\.subscription\?\.getSnapshot === 'function'/);
3031
assert.doesNotMatch(sidebarSource, /selectSubscriptionSnapshot/);
@@ -35,7 +36,6 @@ describe('subscription sidebar and shell wiring', () => {
3536
assert.match(appSource, /currentView === 'subscription' && <SubscriptionPage \/>/);
3637
assert.match(subscriptionPageSource, /const subscriptionBridgeAvailable = typeof window\.electronAPI\.subscription\?\.getSnapshot === 'function';/);
3738
assert.match(subscriptionPageSource, /if \(subscriptionBridgeAvailable && !snapshot && !isLoading\) \{/);
38-
assert.match(subscriptionPageSource, /openStorePage\(HAGICODE_SPONSOR_PLAN_STORE_WEB_URL\)/);
3939
assert.match(subscriptionPageSource, /openStorePage\(HAGICODE_DESKTOP_WINDOWS_STORE_WEB_URL\)/);
4040
assert.match(subscriptionTypesSource, /export const HAGICODE_SPONSOR_PLAN_STORE_WEB_URL = `https:\/\/apps\.microsoft\.com\/detail\/\$\{HAGICODE_SPONSOR_PLAN_STORE_ID\}`;/);
4141
assert.match(subscriptionTypesSource, /export \{\s*HAGICODE_DESKTOP_WINDOWS_STORE_WEB_URL,\s*\};/);

src/renderer/components/SidebarNavigation.tsx

Lines changed: 137 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type ComponentType } from 'react';
22
import { useSelector, useDispatch } from 'react-redux';
33
import { useTranslation } from 'react-i18next';
44
import { motion, AnimatePresence } from 'motion/react';
5+
import { useTheme } from 'next-themes';
56
import {
67
AlertCircle,
78
BadgeCheck,
@@ -27,6 +28,7 @@ import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
2728
import { ScrollArea } from './ui/scroll-area';
2829
import type { DistributionModeState } from '../../types/distribution-mode';
2930
import type { DesktopVersionInfoPayload } from '../../types/version-info';
31+
import { isDesktopTheme } from '../lib/desktop-theme';
3032
import {
3133
createLoadingSidebarAboutFetchState,
3234
hasSidebarAboutEntries,
@@ -38,48 +40,68 @@ import {
3840
type SidebarAboutModel,
3941
type SidebarAboutSectionId,
4042
} from '../lib/about-sidebar';
43+
import { cn } from '../lib/utils';
44+
45+
type NavigationEmphasis = 'default' | 'sponsor' | 'turboengine';
46+
4147
interface NavigationItem {
4248
id: ViewType | 'official-website' | 'cost-calculator';
4349
labelKey: string;
4450
descriptionKey?: string;
4551
icon: ComponentType<{ className?: string }>;
4652
url?: string;
53+
emphasis: NavigationEmphasis;
4754
}
4855

49-
const navigationItems: NavigationItem[] = [
50-
{ id: 'system', labelKey: 'sidebar.dashboard', icon: Settings },
51-
{ id: 'version', labelKey: 'sidebar.versionManagement', icon: FileText },
52-
{ id: 'diagnostic', labelKey: 'sidebar.diagnostic', icon: Stethoscope },
53-
{ id: 'dependency-management', labelKey: 'sidebar.dependencyManagement', icon: PackageOpen },
54-
{ id: 'settings', labelKey: 'sidebar.settings', icon: Settings },
56+
const primaryNavigationItems: NavigationItem[] = [
57+
{ id: 'system', labelKey: 'sidebar.dashboard', icon: Settings, emphasis: 'default' },
58+
{ id: 'version', labelKey: 'sidebar.versionManagement', icon: FileText, emphasis: 'default' },
59+
{ id: 'diagnostic', labelKey: 'sidebar.diagnostic', icon: Stethoscope, emphasis: 'default' },
60+
{ id: 'dependency-management', labelKey: 'sidebar.dependencyManagement', icon: PackageOpen, emphasis: 'default' },
5561
];
5662

63+
const settingsNavigationItem: NavigationItem = {
64+
id: 'settings',
65+
labelKey: 'sidebar.settings',
66+
icon: Settings,
67+
emphasis: 'default',
68+
};
69+
5770
const officialWebsiteItem: NavigationItem = {
5871
id: 'official-website',
5972
labelKey: 'navigation.officialWebsite',
6073
descriptionKey: 'navigation.officialWebsiteDesc',
6174
icon: GlobeIcon,
6275
url: 'https://hagicode.com/',
76+
emphasis: 'default',
6377
};
6478

6579
const subscriptionNavigationItem: NavigationItem = {
6680
id: 'subscription',
6781
labelKey: 'sidebar.subscription',
6882
icon: BadgeCheck,
83+
emphasis: 'sponsor',
6984
};
7085

7186
const turboEngineNavigationItem: NavigationItem = {
7287
id: 'turboengine',
7388
labelKey: 'sidebar.turboEngine',
7489
icon: Cpu,
90+
emphasis: 'turboengine',
7591
};
7692

93+
const featuredNavigationItems: NavigationItem[] = [
94+
subscriptionNavigationItem,
95+
turboEngineNavigationItem,
96+
];
97+
7798
const remainingExternalLinkItems: NavigationItem[] = [
7899
{
79100
id: 'cost-calculator',
80101
labelKey: 'navigation.costCalculator',
81102
url: 'https://cost.hagicode.com',
82103
icon: Calculator,
104+
emphasis: 'default',
83105
},
84106
];
85107

@@ -176,17 +198,61 @@ function AboutBrandLogo({ entry }: { entry: SidebarAboutEntry }) {
176198
);
177199
}
178200

201+
function getFeaturedNavigationPalette(
202+
emphasis: Exclude<NavigationEmphasis, 'default'>,
203+
isDarkTheme: boolean,
204+
) {
205+
if (emphasis === 'sponsor') {
206+
return {
207+
button: 'border-transparent bg-transparent text-foreground shadow-none hover:bg-transparent',
208+
activeButton: 'border-transparent bg-transparent shadow-none',
209+
iconWrap: 'border-transparent bg-transparent',
210+
iconClass: isDarkTheme
211+
? 'text-[#f1cb71] drop-shadow-[0_0_14px_rgba(241,203,113,0.38)] transition-all duration-300 group-hover:scale-110 group-hover:text-[#ffe09a]'
212+
: 'text-[#6e4304] transition-all duration-300 group-hover:scale-110 group-hover:text-[#4d2d00]',
213+
activeIconClass: isDarkTheme ? 'text-[#ffe8b8]' : 'text-[#4d2d00]',
214+
labelClass: isDarkTheme
215+
? 'bg-[linear-gradient(90deg,#f7d882,#fff1c8,#e0a93a)] bg-clip-text text-transparent'
216+
: 'bg-[linear-gradient(90deg,#a06b18_0%,#171717_48%,#171717_52%,#a06b18_100%)] bg-clip-text text-transparent',
217+
activeLabelClass: isDarkTheme
218+
? 'bg-[linear-gradient(90deg,#ffe5a2,#fff6da,#f0bb52)] bg-clip-text text-transparent'
219+
: 'bg-[linear-gradient(90deg,#7d4f0d_0%,#050505_48%,#050505_52%,#7d4f0d_100%)] bg-clip-text text-transparent',
220+
focus: 'focus-visible:ring-[#f0cf80]/45',
221+
};
222+
}
223+
224+
return {
225+
button: 'border-transparent bg-transparent text-foreground shadow-none hover:bg-transparent',
226+
activeButton: 'border-transparent bg-transparent shadow-none',
227+
iconWrap: 'border-transparent bg-transparent',
228+
iconClass: isDarkTheme
229+
? 'text-[#ffbe57] drop-shadow-[0_0_14px_rgba(255,190,87,0.38)] transition-all duration-300 group-hover:scale-110 group-hover:text-[#ffd48c]'
230+
: 'text-[#8a4300] transition-all duration-300 group-hover:scale-110 group-hover:text-[#682700]',
231+
activeIconClass: isDarkTheme ? 'text-[#ffe0a2]' : 'text-[#682700]',
232+
labelClass: isDarkTheme
233+
? 'bg-[linear-gradient(90deg,#ffca6e,#ffe6a8,#e89b2f)] bg-clip-text text-transparent'
234+
: 'bg-[linear-gradient(90deg,#b46112_0%,#171717_48%,#171717_52%,#b46112_100%)] bg-clip-text text-transparent',
235+
activeLabelClass: isDarkTheme
236+
? 'bg-[linear-gradient(90deg,#ffe0a2,#fff0c8,#f5b246)] bg-clip-text text-transparent'
237+
: 'bg-[linear-gradient(90deg,#8b4308_0%,#050505_48%,#050505_52%,#8b4308_100%)] bg-clip-text text-transparent',
238+
focus: 'focus-visible:ring-[#ffc86b]/45',
239+
};
240+
}
241+
179242
export default function SidebarNavigation({ distributionState }: SidebarNavigationProps) {
180243
const { t, i18n } = useTranslation('common');
244+
const { resolvedTheme } = useTheme();
181245
const dispatch = useDispatch();
182246
const currentView = useSelector((state: RootState) => state.view.currentView);
247+
const activeTheme = isDesktopTheme(resolvedTheme) ? resolvedTheme : 'light';
248+
const isDarkTheme = activeTheme === 'dark';
183249
const isFusionMode = distributionState.fusionMode;
184250
const visibleNavigationItems = useMemo(() => {
185251
const baseItems = isFusionMode
186-
? navigationItems.filter((item) => item.id !== 'version')
187-
: navigationItems;
252+
? primaryNavigationItems.filter((item) => item.id !== 'version')
253+
: primaryNavigationItems;
188254

189-
return [...baseItems, subscriptionNavigationItem, turboEngineNavigationItem];
255+
return [...baseItems, ...featuredNavigationItems, settingsNavigationItem];
190256
}, [isFusionMode]);
191257
const aboutLocale = useMemo(
192258
() => normalizeSidebarAboutLocale(i18n.resolvedLanguage ?? i18n.language),
@@ -489,6 +555,10 @@ export default function SidebarNavigation({ distributionState }: SidebarNavigati
489555
{visibleNavigationItems.map((item, index) => {
490556
const Icon = item.icon;
491557
const isActive = isNavActive(item);
558+
const featuredPalette = item.emphasis === 'default'
559+
? null
560+
: getFeaturedNavigationPalette(item.emphasis, isDarkTheme);
561+
const isFeatured = featuredPalette !== null;
492562

493563
return (
494564
<motion.button
@@ -497,18 +567,22 @@ export default function SidebarNavigation({ distributionState }: SidebarNavigati
497567
initial={{ opacity: 0, x: -20 }}
498568
animate={{ opacity: 1, x: 0 }}
499569
transition={{ delay: index * 0.05, duration: 0.3 }}
500-
whileHover={{ x: 0 }}
570+
whileHover={isFeatured ? { x: 0, y: -2 } : { x: 0 }}
501571
whileTap={{ scale: 0.98 }}
502-
className={`
503-
relative flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-left
504-
overflow-hidden group
505-
${isActive
506-
? 'border border-border/80 bg-accent text-foreground shadow-sm'
507-
: 'border border-transparent text-muted-foreground hover:border-border/70 hover:bg-muted/55 hover:text-foreground'
508-
}
509-
`}
572+
className={cn(
573+
'group relative flex w-full items-center gap-3 overflow-hidden rounded-xl border px-3 py-2.5 text-left transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
574+
isFeatured
575+
? cn(
576+
featuredPalette?.button,
577+
featuredPalette?.focus,
578+
isActive && featuredPalette?.activeButton,
579+
)
580+
: isActive
581+
? 'border-border/80 bg-accent text-foreground shadow-sm focus-visible:ring-primary/35'
582+
: 'border-transparent text-muted-foreground hover:border-border/70 hover:bg-muted/55 hover:text-foreground focus-visible:ring-primary/25',
583+
)}
510584
>
511-
{!isActive && (
585+
{!isFeatured && !isActive && (
512586
<motion.div
513587
initial={{ opacity: 0 }}
514588
whileHover={{ opacity: 1 }}
@@ -517,7 +591,28 @@ export default function SidebarNavigation({ distributionState }: SidebarNavigati
517591
/>
518592
)}
519593

520-
<Icon className={`relative z-10 h-5 w-5 flex-shrink-0 ${isActive ? 'text-primary' : ''}`} />
594+
<div
595+
className={cn(
596+
'relative z-10 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg border transition-all duration-300',
597+
isFeatured
598+
? featuredPalette?.iconWrap
599+
: isActive
600+
? 'border-primary/25 bg-primary/10 text-primary'
601+
: 'border-border/60 bg-background/75 text-muted-foreground group-hover:border-border/80 group-hover:bg-background',
602+
)}
603+
>
604+
<Icon
605+
className={cn(
606+
'h-4 w-4',
607+
isFeatured
608+
? cn(
609+
featuredPalette?.iconClass,
610+
isActive && featuredPalette?.activeIconClass,
611+
)
612+
: '',
613+
)}
614+
/>
615+
</div>
521616

522617
<AnimatePresence mode="wait">
523618
{!collapsed && (
@@ -526,9 +621,23 @@ export default function SidebarNavigation({ distributionState }: SidebarNavigati
526621
animate={{ opacity: 1, width: 'auto' }}
527622
exit={{ opacity: 0, width: 0 }}
528623
transition={{ duration: 0.2 }}
529-
className="relative z-10 flex min-w-0 items-center gap-2"
624+
className={cn(
625+
'relative z-10 flex min-w-0 items-center gap-2',
626+
!isFeatured && 'pr-5',
627+
)}
530628
>
531-
<span className="truncate font-medium text-sm whitespace-nowrap">
629+
<span
630+
className={cn(
631+
'truncate whitespace-nowrap text-sm',
632+
isFeatured
633+
? cn(
634+
'font-semibold tracking-[0.01em]',
635+
featuredPalette?.labelClass,
636+
isActive && featuredPalette?.activeLabelClass,
637+
)
638+
: 'font-medium',
639+
)}
640+
>
532641
{t(item.labelKey)}
533642
</span>
534643
</motion.div>
@@ -554,7 +663,12 @@ export default function SidebarNavigation({ distributionState }: SidebarNavigati
554663
transition={{ delay: 0.2, duration: 0.3 }}
555664
className="min-h-0 flex-1 overflow-hidden"
556665
>
557-
<ScrollArea className="h-full" type="always">
666+
<ScrollArea
667+
className="h-full"
668+
type="scroll"
669+
scrollBarClassName="w-1.5 p-0.5"
670+
scrollThumbClassName="bg-border/65"
671+
>
558672
<div className="space-y-2 pb-6">
559673
<motion.button
560674
type="button"

src/renderer/components/SidebarNavigation.turboengine.render.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ describe('TurboEngine sidebar and shell wiring', () => {
2424
assert.match(sidebarSource, /const turboEngineNavigationItem: NavigationItem = \{/);
2525
assert.match(sidebarSource, /id: 'turboengine'/);
2626
assert.match(sidebarSource, /labelKey: 'sidebar\.turboEngine'/);
27-
assert.match(sidebarSource, /return \[\.\.\.baseItems, subscriptionNavigationItem, turboEngineNavigationItem\];/);
27+
assert.match(sidebarSource, /emphasis: 'turboengine'/);
28+
assert.match(sidebarSource, /return \[\.\.\.baseItems, \.\.\.featuredNavigationItems, settingsNavigationItem\];/);
2829
assert.match(appSource, /import TurboEnginePage from '\.\/components\/turboengine\/TurboEnginePage';/);
2930
assert.match(appSource, /currentView === 'turboengine' && <TurboEnginePage \/>/);
3031
assert.match(turboPageSource, /const turboEngineBridgeAvailable = typeof window\.electronAPI\.turboEngineLicense\?\.getSnapshot === 'function';/);
31-
assert.match(turboPageSource, /openStorePage\(HAGICODE_TURBOENGINE_STORE_WEB_URL\)/);
3232
assert.match(turboPageSource, /openStorePage\(HAGICODE_DESKTOP_WINDOWS_STORE_WEB_URL\)/);
3333
assert.match(turboTypesSource, /export const HAGICODE_TURBOENGINE_STORE_ID = '9NSD809W18Z6';/);
3434
assert.match(storeSource, /setTurboEngineLicenseSnapshotFromEvent/);

src/renderer/components/SystemManagementView.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,6 @@ export default function SystemManagementView({
753753

754754
<section className="rounded-3xl border border-transparent px-1 py-2 text-center">
755755
<p className="text-sm text-muted-foreground">{t('footer.copyright')}</p>
756-
<p className="mt-2 text-xs text-muted-foreground/80">{t('footer.testBuild')}</p>
757756
</section>
758757
</div>
759758
);

src/renderer/components/WebServiceStatusCard.tsx

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -741,39 +741,32 @@ const WebServiceStatusCard: React.FC = () => {
741741
setSelectedListenPreset(value as ListenHostPreset);
742742
setNetworkConfigError(null);
743743
}}
744-
className="gap-3"
744+
className="grid grid-cols-4 gap-2"
745745
>
746746
{[
747747
{
748748
value: 'localhost',
749749
label: t('webServiceStatus.listenAddress.presets.localhost.label'),
750-
description: t('webServiceStatus.listenAddress.presets.localhost.description'),
751750
},
752751
{
753752
value: '127.0.0.1',
754753
label: t('webServiceStatus.listenAddress.presets.loopback.label'),
755-
description: t('webServiceStatus.listenAddress.presets.loopback.description'),
756754
},
757755
{
758756
value: '0.0.0.0',
759757
label: t('webServiceStatus.listenAddress.presets.wildcard.label'),
760-
description: t('webServiceStatus.listenAddress.presets.wildcard.description'),
761758
},
762759
{
763760
value: 'custom',
764761
label: t('webServiceStatus.listenAddress.presets.custom.label'),
765-
description: t('webServiceStatus.listenAddress.presets.custom.description'),
766762
},
767763
].map((option) => (
768764
<label
769765
key={option.value}
770-
className="flex cursor-pointer items-start gap-3 rounded-xl border border-border/60 bg-background/80 p-3 transition-colors hover:border-primary/35"
766+
className="flex cursor-pointer items-center gap-2 rounded-xl border border-border/60 bg-background/80 px-3 py-2 transition-colors hover:border-primary/35"
771767
>
772-
<RadioGroupItem value={option.value} className="mt-0.5" />
773-
<div className="space-y-1">
774-
<div className="text-sm font-medium">{option.label}</div>
775-
<div className="text-xs text-muted-foreground">{option.description}</div>
776-
</div>
768+
<RadioGroupItem value={option.value} />
769+
<div className="min-w-0 text-sm font-medium">{option.label}</div>
777770
</label>
778771
))}
779772
</RadioGroup>

0 commit comments

Comments
 (0)