UI updates based on impeccable passes

This commit is contained in:
lone-cloud 2026-04-12 15:35:33 -07:00
parent bbea8a537b
commit 8c1082627f
Signed by: lone-cloud
GPG key ID: B0848536D672CD8D
36 changed files with 320 additions and 325 deletions

2
.gitignore vendored
View file

@ -3,6 +3,8 @@ dist/
dist-electron/
out/
release/
.agents/
.impeccable.md
*.log
.DS_Store
Thumbs.db

View file

@ -1,6 +1,6 @@
{
"name": "gerbil",
"version": "1.22.1",
"version": "1.23.0",
"description": "Run Large Language Models locally",
"keywords": [
"ai",
@ -42,7 +42,7 @@
"@codemirror/search": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
"@fontsource/inter": "^5.2.8",
"@fontsource/geist": "^5.2.8",
"@huggingface/gguf": "^0.4.2",
"@mantine/core": "^9.0.1",
"@mantine/hooks": "^9.0.1",

8
pnpm-lock.yaml generated
View file

@ -42,7 +42,7 @@ importers:
'@codemirror/view':
specifier: ^6.41.0
version: 6.41.0
'@fontsource/inter':
'@fontsource/geist':
specifier: ^5.2.8
version: 5.2.8
'@huggingface/gguf':
@ -489,8 +489,8 @@ packages:
'@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@fontsource/inter@5.2.8':
resolution: {integrity: sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==}
'@fontsource/geist@5.2.8':
resolution: {integrity: sha512-pI54klK6vz8fVpAVyV+iODGLEzw73cs1XJqV9PIXgW8MYMmBcwK6lKKaWqJrNHwEx8ppz9cuhussb6arXQ/PVQ==}
'@huggingface/gguf@0.4.2':
resolution: {integrity: sha512-pog57zGj/u8uZ6cFi2vH9MkWvq+ZtFDa5iUeNxggDSle+04htJQFbl31TcauGUhnxTBJokFexWK9WL2BakLR0Q==}
@ -3530,7 +3530,7 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
'@fontsource/inter@5.2.8': {}
'@fontsource/geist@5.2.8': {}
'@huggingface/gguf@0.4.2':
dependencies:

View file

@ -1,6 +1,17 @@
import { ActionIcon, Button, Tooltip } from '@mantine/core';
import { Activity } from 'lucide-react';
const BADGE_STYLE = {
borderRadius: '0.75rem',
fontSize: '0.7em',
fontWeight: 500,
height: 'auto',
margin: '0.125rem 0',
minWidth: '5rem',
padding: '0.25rem 0.5rem',
textAlign: 'center',
} as const;
interface PerformanceBadgeProps {
label?: string;
value?: string;
@ -25,7 +36,12 @@ export const PerformanceBadge = ({
if (iconOnly) {
return (
<Tooltip label={tooltipLabel} position="top">
<ActionIcon size="sm" variant="subtle" onClick={() => void handlePerformanceClick()}>
<ActionIcon
size="sm"
variant="subtle"
aria-label="View performance details"
onClick={() => void handlePerformanceClick()}
>
<Activity size="1.125rem" />
</ActionIcon>
</Tooltip>
@ -37,16 +53,7 @@ export const PerformanceBadge = ({
<Button
size="xs"
variant="light"
style={{
borderRadius: '0.75rem',
fontSize: '0.7em',
fontWeight: 500,
height: 'auto',
margin: '0.125rem 0',
minWidth: '5rem',
padding: '0.25rem 0.5rem',
textAlign: 'center',
}}
style={BADGE_STYLE}
onClick={() => void handlePerformanceClick()}
>
{label}: {value}

View file

@ -27,7 +27,7 @@ export const AppRouter = ({
const isInterfaceScreen = currentScreen === 'interface';
return (
<>
<main style={{ display: 'contents' }}>
<ScreenTransition isActive={currentScreen === 'welcome'} shouldAnimate={hasInitialized}>
<WelcomeScreen onGetStarted={onWelcomeComplete} />
</ScreenTransition>
@ -43,6 +43,6 @@ export const AppRouter = ({
<ScreenTransition isActive={isInterfaceScreen} shouldAnimate={hasInitialized}>
<InterfaceScreen activeTab={activeInterfaceTab} isServerReady={isServerReady} />
</ScreenTransition>
</>
</main>
);
};

View file

@ -13,12 +13,14 @@ export const ScreenTransition = ({ isActive, shouldAnimate, children }: ScreenTr
transition="fade"
duration={shouldAnimate ? 100 : 0}
timingFunction="ease-out"
keepMounted={false}
>
{(styles) => (
<div
style={{
...styles,
}}
inert={!isActive ? true : undefined}
>
{children}
</div>

View file

@ -15,12 +15,8 @@ export const StatusBar = () => {
const [memoryMetrics, setMemoryMetrics] = useState<MemoryMetrics | null>(null);
const [gpuMetrics, setGpuMetrics] = useState<GpuMetrics | null>(null);
const [tunnelBaseUrl, setTunnelBaseUrl] = useState<string | null>(null);
const {
resolvedColorScheme: colorScheme,
systemMonitoringEnabled,
frontendPreference,
imageGenerationFrontendPreference,
} = usePreferencesStore();
const { systemMonitoringEnabled, frontendPreference, imageGenerationFrontendPreference } =
usePreferencesStore();
const { isVisible, setVisible } = useNotepadStore();
const { isImageGenerationMode } = useLaunchConfigStore();
@ -100,13 +96,16 @@ export const StatusBar = () => {
gap="xs"
justify="space-between"
h="100%"
bg={colorScheme === 'dark' ? 'dark.6' : 'gray.1'}
style={{
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6))',
}}
>
<Group gap="xs">
<Tooltip label="Notepad" disabled={isVisible}>
<ActionIcon
variant={isVisible ? 'filled' : 'subtle'}
size="sm"
aria-label="Toggle notepad"
onClick={() => setVisible(!isVisible)}
>
<NotepadText size="1.25rem" />
@ -120,6 +119,7 @@ export const StatusBar = () => {
variant="subtle"
size="sm"
color={copied ? 'teal' : undefined}
aria-label={copied ? 'Copied!' : 'Copy tunnel URL'}
onClick={copy}
>
{copied ? <Check size="1.25rem" /> : <Globe size="1.25rem" />}

View file

@ -33,11 +33,7 @@ const renderOption = ({ option }: { option: SelectOption }) => (
);
export const TitleBar = ({ currentScreen, currentTab, onEject, onTabChange }: TitleBarProps) => {
const {
resolvedColorScheme: colorScheme,
frontendPreference,
imageGenerationFrontendPreference,
} = usePreferencesStore();
const { frontendPreference, imageGenerationFrontendPreference } = usePreferencesStore();
const { handleLogoClick, getLogoStyles } = useLogoClickSounds();
const [isMaximized, setIsMaximized] = useState(false);
const [isSelectOpen, setIsSelectOpen] = useState(false);
@ -74,8 +70,7 @@ export const TitleBar = ({ currentScreen, currentTab, onEject, onTabChange }: Ti
style={{
WebkitAppRegion: isSelectOpen ? 'no-drag' : 'drag',
alignItems: 'center',
backgroundColor:
colorScheme === 'dark' ? 'var(--mantine-color-dark-6)' : 'var(--mantine-color-gray-1)',
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6))',
borderBottom: '1px solid var(--mantine-color-default-border)',
display: 'flex',
height: TITLEBAR_HEIGHT,
@ -154,9 +149,7 @@ export const TitleBar = ({ currentScreen, currentTab, onEject, onTabChange }: Ti
<Box
style={{
backgroundColor:
colorScheme === 'dark'
? 'var(--mantine-color-dark-3)'
: 'var(--mantine-color-gray-4)',
'light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-3))',
height: '1.25rem',
margin: '0 0.25rem',
width: '0.1rem',

View file

@ -54,7 +54,7 @@ export const UpdateAvailableModal = ({
closeOnEscape={!isDownloading && !isUpdating}
>
<Stack gap="md">
<Card withBorder radius="md" p="md" bd="2px solid orange">
<Card withBorder radius="md" p="md" bd="2px solid var(--mantine-color-orange-5)">
<Stack gap="xs">
<Group gap="md" align="center">
<div>

View file

@ -72,7 +72,6 @@ export const UpdateButton = () => {
color={color}
size={TITLEBAR_HEIGHT}
aria-label={label}
tabIndex={-1}
onClick={onClick}
onContextMenu={handleContextMenu}
style={{

View file

@ -3,7 +3,6 @@ import { Download, Trash2 } from 'lucide-react';
import type { MouseEvent } from 'react';
import { useKoboldBackendsStore } from '@/stores/koboldBackends';
import { usePreferencesStore } from '@/stores/preferences';
import type { BackendInfo } from '@/types';
import { isWindowsROCmBuild, pretifyBinName } from '@/utils/assets';
@ -30,7 +29,6 @@ export const DownloadCard = ({
onRedownload,
onDelete,
}: DownloadCardProps) => {
const { resolvedColorScheme: colorScheme } = usePreferencesStore();
const { downloading, downloadProgress } = useKoboldBackendsStore();
const isLoading = downloading === backend.name;
@ -156,8 +154,10 @@ export const DownloadCard = ({
radius="sm"
padding="sm"
{...(backend.isCurrent && {
bd: `2px solid var(--mantine-color-${colorScheme === 'dark' ? 'blue-4' : 'blue-6'})`,
bg: colorScheme === 'dark' ? 'dark.6' : 'gray.0',
style: {
border: 'light-dark(var(--mantine-color-brand-6), var(--mantine-color-brand-4))',
backgroundColor: 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))',
},
})}
>
<Group justify="space-between" align="center">
@ -167,7 +167,7 @@ export const DownloadCard = ({
{pretifyBinName(backend.name)}
</Text>
{backend.isCurrent && (
<Badge variant="light" color="blue" size="sm">
<Badge variant="light" color="brand" size="sm">
Current
</Badge>
)}
@ -217,8 +217,8 @@ export const DownloadCard = ({
{isLoading && currentProgress !== undefined && (
<Stack gap="xs" mt="sm">
<Progress value={currentProgress} color="blue" radius="xl" />
<Text size="xs" c="dimmed" ta="center">
<Progress value={currentProgress} color="brand" radius="xl" />
<Text size="xs" c="dimmed" ta="center" aria-live="polite">
{currentProgress === 100
? '100.0% complete'
: `${currentProgress.toFixed(1).padStart(5, ' ')}% complete`}

View file

@ -9,7 +9,7 @@ interface InfoTooltipProps {
export const InfoTooltip = ({ label, multiline = true, width = 300 }: InfoTooltipProps) => (
<Tooltip label={label} multiline={multiline} w={width}>
<ActionIcon variant="subtle" size="xs" color="gray">
<ActionIcon variant="subtle" size="sm" color="gray" aria-label="More information">
<Info size={14} />
</ActionIcon>
</Tooltip>

View file

@ -5,7 +5,6 @@ import type { MouseEvent } from 'react';
import { NOTEPAD_MIN_HEIGHT, NOTEPAD_MIN_WIDTH } from '@/constants/notepad';
import { useNotepadStore } from '@/stores/notepad';
import { usePreferencesStore } from '@/stores/preferences';
import { CloseConfirmModal } from './CloseConfirmModal.tsx';
import { NotepadEditor } from './Editor.tsx';
@ -23,7 +22,6 @@ export const NotepadContainer = () => {
isVisible,
setVisible,
} = useNotepadStore();
const { resolvedColorScheme } = usePreferencesStore();
const containerRef = useRef<HTMLDivElement>(null);
const [resizeDirection, setResizeDirection] = useState<string | null>(null);
@ -125,10 +123,7 @@ export const NotepadContainer = () => {
shadow="lg"
withBorder
style={{
backgroundColor:
resolvedColorScheme === 'dark'
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-white)',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
bottom: 24,
cursor: 'default',
height: position.height,
@ -199,11 +194,8 @@ export const NotepadContainer = () => {
<Box
style={{
alignItems: 'center',
borderBottom: `1px solid ${
resolvedColorScheme === 'dark'
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-3)'
}`,
borderBottom:
'1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))',
display: 'flex',
justifyContent: 'space-between',
minHeight: 28,
@ -220,13 +212,31 @@ export const NotepadContainer = () => {
paddingRight: 8,
}}
>
<ActionIcon variant="subtle" size="xs" onClick={() => setVisible(false)}>
<ActionIcon
variant="subtle"
size="sm"
aria-label="Minimize notepad"
onClick={() => setVisible(false)}
>
<Minus size="1rem" />
</ActionIcon>
</Box>
</Box>
<Box style={{ flex: 1, position: 'relative' }}>
<Box
role="tabpanel"
id={
activeTab
? `notepad-panel-${activeTab.title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`
: undefined
}
aria-labelledby={
activeTab
? `notepad-tab-${activeTab.title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`
: undefined
}
style={{ flex: 1, position: 'relative' }}
>
{activeTab && <NotepadEditor key={activeTab.title} tab={activeTab} />}
</Box>
</Box>

View file

@ -20,24 +20,22 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
const { saveTabContent, showLineNumbers, setShowLineNumbers } = useNotepadStore();
const { resolvedColorScheme } = usePreferencesStore();
const [content, setContent] = useState(() => tab.content);
const [saveTimeout, setSaveTimeout] = useState<ReturnType<typeof setTimeout> | null>(null);
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const editorRef = useRef<ReactCodeMirrorRef>(null);
const handleContentChange = useCallback(
(newContent: string) => {
setContent(newContent);
if (saveTimeout) {
clearTimeout(saveTimeout);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
const timeout = setTimeout(() => {
saveTimeoutRef.current = setTimeout(() => {
void saveTabContent(tab.title, newContent);
}, 500);
setSaveTimeout(timeout);
},
[tab.title, saveTabContent, saveTimeout],
[tab.title, saveTabContent],
);
const handleEditorContextMenu = (e: MouseEvent) => {
@ -56,11 +54,11 @@ export const NotepadEditor = ({ tab }: NotepadEditorProps) => {
useEffect(
() => () => {
if (saveTimeout) {
clearTimeout(saveTimeout);
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
},
[saveTimeout],
[],
);
const extensions = [

View file

@ -3,8 +3,6 @@ import { X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import type { DragEvent, KeyboardEvent, MouseEvent } from 'react';
import { usePreferencesStore } from '@/stores/preferences';
interface TabProps {
title: string;
index: number;
@ -34,7 +32,6 @@ export const Tab = ({
showLineNumbers,
setShowLineNumbers,
}: TabProps) => {
const { resolvedColorScheme } = usePreferencesStore();
const [isEditing, setIsEditing] = useState(false);
const [editingTitle, setEditingTitle] = useState(title);
const inputRef = useRef<HTMLInputElement>(null);
@ -97,28 +94,32 @@ export const Tab = ({
return (
<Box
id={`notepad-tab-${title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`}
role="tab"
aria-selected={isActive}
aria-controls={`notepad-panel-${title.replace(/[^a-z0-9]/gi, '-').toLowerCase()}`}
tabIndex={isActive ? 0 : -1}
draggable={!isEditing}
onClick={handleTabClick}
onMouseDown={handleMouseDown}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (!isEditing && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleTabClick();
}
}}
onDragStart={(e) => onDragStart(e, index)}
onDragOver={onDragOver}
onDrop={(e) => onDrop(e, index)}
style={{
alignItems: 'center',
backgroundColor: isActive
? resolvedColorScheme === 'dark'
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-1)'
? 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-4))'
: isDragOver
? resolvedColorScheme === 'dark'
? 'var(--mantine-color-dark-5)'
: 'var(--mantine-color-gray-2)'
? 'light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))'
: 'transparent',
borderRight: `1px solid ${
resolvedColorScheme === 'dark'
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-3)'
}`,
borderRight:
'1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))',
cursor: isEditing ? 'default' : 'pointer',
display: 'flex',
gap: '0.25rem',
@ -173,7 +174,7 @@ export const Tab = ({
</Text>
)}
<ActionIcon variant="subtle" size="xs" onClick={onClose}>
<ActionIcon variant="subtle" size="sm" aria-label="Close tab" onClick={onClose}>
<X size="0.625rem" />
</ActionIcon>
</Box>

View file

@ -1,11 +1,10 @@
import { ActionIcon, Box } from '@mantine/core';
import { Plus } from 'lucide-react';
import type { DragEvent, MouseEvent } from 'react';
import type { DragEvent, KeyboardEvent, MouseEvent } from 'react';
import { useState } from 'react';
import { Tab } from '@/components/Notepad/Tab';
import { useNotepadStore } from '@/stores/notepad';
import { usePreferencesStore } from '@/stores/preferences';
interface NotepadTabsProps {
onCreateNewTab: () => Promise<void>;
@ -27,7 +26,6 @@ export const NotepadTabs = ({ onCreateNewTab, onCloseTab }: NotepadTabsProps) =>
showLineNumbers,
setShowLineNumbers,
} = useNotepadStore();
const { resolvedColorScheme } = usePreferencesStore();
const [draggedTabIndex, setDraggedTabIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
@ -53,6 +51,19 @@ export const NotepadTabs = ({ onCreateNewTab, onCloseTab }: NotepadTabsProps) =>
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabs.findIndex((t) => t.title === activeTabId);
if (e.key === 'ArrowRight') {
e.preventDefault();
const next = (currentIndex + 1) % tabs.length;
setActiveTab(tabs[next].title);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prev = (currentIndex - 1 + tabs.length) % tabs.length;
setActiveTab(tabs[prev].title);
}
};
const handleDragStart = (e: DragEvent, index: number) => {
setDraggedTabIndex(index);
e.dataTransfer.effectAllowed = 'move';
@ -79,13 +90,13 @@ export const NotepadTabs = ({ onCreateNewTab, onCloseTab }: NotepadTabsProps) =>
return (
<Box
role="tablist"
aria-label="Notepad tabs"
onContextMenu={handleTabBarContextMenu}
onKeyDown={handleKeyDown}
style={{
borderBottom: `1px solid ${
resolvedColorScheme === 'dark'
? 'var(--mantine-color-dark-4)'
: 'var(--mantine-color-gray-3)'
}`,
borderBottom:
'1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))',
display: 'flex',
minHeight: '2rem',
overflow: 'hidden',
@ -116,7 +127,8 @@ export const NotepadTabs = ({ onCreateNewTab, onCloseTab }: NotepadTabsProps) =>
<ActionIcon
variant="subtle"
size="xs"
size="sm"
aria-label="New tab"
onClick={() => void onCreateNewTab()}
style={{
alignSelf: 'center',

View file

@ -5,8 +5,8 @@ export const Switch = (props: SwitchProps) => (
<MantineSwitch
{...props}
styles={{
thumb: { transition: 'none' },
track: { transition: 'none' },
thumb: { transition: 'left 100ms ease, background-color 100ms ease' },
track: { transition: 'background-color 100ms ease' },
}}
/>
);

View file

@ -29,8 +29,8 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
warningMessages[0].message
) : (
<List size="sm" spacing={4}>
{warningMessages.map((warning, index) => (
<List.Item key={index}>{warning.message}</List.Item>
{warningMessages.map((warning) => (
<List.Item key={warning.message}>{warning.message}</List.Item>
))}
</List>
)
@ -48,8 +48,8 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
infoMessages[0].message
) : (
<List size="sm" spacing={4}>
{infoMessages.map((info, index) => (
<List.Item key={index}>{info.message}</List.Item>
{infoMessages.map((info) => (
<List.Item key={info.message}>{info.message}</List.Item>
))}
</List>
)
@ -57,7 +57,7 @@ export const WarningDisplay = ({ warnings, children }: WarningDisplayProps) => {
multiline
maw={320}
>
<Info size={18} color="var(--mantine-color-blue-6)" strokeWidth={2} />
<Info size={18} color="var(--mantine-color-brand-5)" strokeWidth={2} />
</Tooltip>
)}
{children}

View file

@ -1,4 +1,4 @@
import { Card, Container, Loader, Stack, Text, Title } from '@mantine/core';
import { Container, Loader, Stack, Text, Title } from '@mantine/core';
import { useCallback, useEffect, useRef, useState } from 'react';
import { DownloadCard } from '@/components/DownloadCard';
@ -59,66 +59,62 @@ export const DownloadScreen = ({ onDownloadComplete }: DownloadScreenProps) => {
return (
<Container size="sm" mt="md">
<Card withBorder radius="md" shadow="sm">
<Stack gap="lg">
<Title order={3}>Select a Backend</Title>
<Stack gap="lg">
<Title order={3}>Select a Backend</Title>
{loading ? (
<Stack align="center" gap="md" py="xl">
<Loader color="blue" />
<Text c="dimmed">Preparing download options...</Text>
</Stack>
) : (
<>
{availableDownloads.length > 0 ? (
<Stack gap="sm">
{availableDownloads.map((download) => {
const isDownloading =
Boolean(downloading) && downloadingAsset === download.name;
{loading ? (
<Stack align="center" gap="md" py="xl">
<Loader color="brand" />
<Text c="dimmed">Preparing download options...</Text>
</Stack>
) : (
<>
{availableDownloads.length > 0 ? (
<Stack gap="sm">
{availableDownloads.map((download) => {
const isDownloading = Boolean(downloading) && downloadingAsset === download.name;
return (
<div key={download.name} ref={isDownloading ? downloadingItemRef : null}>
<DownloadCard
backend={{
downloadUrl: download.url,
hasUpdate: false,
isCurrent: false,
isInstalled: false,
name: download.name,
size: download.size,
version: download.version ?? '',
}}
size={formatDownloadSize(download.size, download.url)}
description={getAssetDescription(download.name)}
disabled={
importing ||
(Boolean(downloading) && downloadingAsset !== download.name)
}
onDownload={(e) => {
e.stopPropagation();
void handleDownload(download);
}}
/>
</div>
);
})}
</Stack>
) : (
<Text size="sm" c="dimmed" ta="center">
Unable to fetch downloads for your platform ({getPlatformDisplayName(platform)}).
Check your internet connection and try again.
</Text>
)}
return (
<div key={download.name} ref={isDownloading ? downloadingItemRef : null}>
<DownloadCard
backend={{
downloadUrl: download.url,
hasUpdate: false,
isCurrent: false,
isInstalled: false,
name: download.name,
size: download.size,
version: download.version ?? '',
}}
size={formatDownloadSize(download.size, download.url)}
description={getAssetDescription(download.name)}
disabled={
importing || (Boolean(downloading) && downloadingAsset !== download.name)
}
onDownload={(e) => {
e.stopPropagation();
void handleDownload(download);
}}
/>
</div>
);
})}
</Stack>
) : (
<Text size="sm" c="dimmed" ta="center">
Unable to fetch downloads for your platform ({getPlatformDisplayName(platform)}).
Check your internet connection and try again.
</Text>
)}
<ImportBackendLink
disabled={Boolean(downloading)}
onSuccess={onDownloadComplete}
onImportingChange={setImporting}
/>
</>
)}
</Stack>
</Card>
<ImportBackendLink
disabled={Boolean(downloading)}
onSuccess={onDownloadComplete}
onImportingChange={setImporting}
/>
</>
)}
</Stack>
</Container>
);
};

View file

@ -3,7 +3,6 @@ import { ChevronDown } from 'lucide-react';
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { STATUSBAR_HEIGHT, TITLEBAR_HEIGHT } from '@/constants';
import { usePreferencesStore } from '@/stores/preferences';
import { handleTerminalOutput, processTerminalContent } from '@/utils/terminal';
export interface TerminalTabRef {
@ -11,7 +10,6 @@ export interface TerminalTabRef {
}
export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
const { resolvedColorScheme: colorScheme } = usePreferencesStore();
const [terminalContent, setTerminalContent] = useState('');
const [isUserScrolling, setIsUserScrolling] = useState(false);
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
@ -87,9 +85,7 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
<Box
style={{
backgroundColor:
colorScheme === 'dark'
? 'var(--mantine-color-dark-filled)'
: 'var(--mantine-color-gray-0)',
'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-filled))',
borderRadius: 'inherit',
display: 'flex',
flexDirection: 'column',
@ -101,22 +97,14 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
ref={scrollAreaRef}
viewportRef={viewportRef}
onScrollPositionChange={handleScroll}
style={{
flex: 1,
fontFamily: 'Monaco, Menlo, Ubuntu Mono, monospace',
fontSize: '0.875em',
lineHeight: 1.4,
}}
style={{ flex: 1 }}
scrollbarSize={8}
offsetScrollbars={false}
>
<Box p="md">
<div
style={{
color:
colorScheme === 'dark'
? 'var(--mantine-color-gray-0)'
: 'var(--mantine-color-dark-filled)',
color: 'light-dark(var(--mantine-color-dark-filled), var(--mantine-color-gray-0))',
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: '0.875em',
@ -137,13 +125,13 @@ export const TerminalTab = forwardRef<TerminalTabRef>((_props, ref) => {
{isUserScrolling && !shouldAutoScroll && (
<ActionIcon
variant="filled"
color="blue"
color="brand"
size="lg"
radius="xl"
onClick={scrollToBottom}
style={{
bottom: '1.25rem',
boxShadow: '0 0.125rem 0.5rem rgba(0, 0, 0, 0.3)',
boxShadow: 'var(--gerbil-shadow-sm)',
position: 'absolute',
right: '1.25rem',
zIndex: 10,

View file

@ -159,7 +159,7 @@ export const AdvancedTab = () => {
</Group>
<Stack gap="xs">
{preLaunchCommands.map((command, index) => (
<Group key={index} gap="xs">
<Group key={`cmd-${index}-${command}`} gap="xs">
<TextInput
placeholder="Enter a shell command"
value={command}
@ -174,6 +174,7 @@ export const AdvancedTab = () => {
variant="subtle"
color="red"
disabled={preLaunchCommands.length === 1}
aria-label={`Remove command ${index + 1}`}
onClick={() => {
const newCommands = preLaunchCommands.filter((_, i) => i !== index);
setPreLaunchCommands(newCommands.length === 0 ? [''] : newCommands);

View file

@ -885,7 +885,11 @@ export const CommandLineArgumentsModal = ({
gap="xs"
p="sm"
style={{
borderLeft: '3px solid var(--mantine-color-blue-4)',
background:
'light-dark(var(--mantine-color-brand-0), var(--mantine-color-dark-6))',
borderRadius: 'var(--mantine-radius-sm)',
border:
'1px solid light-dark(var(--mantine-color-brand-2), var(--mantine-color-dark-4))',
}}
>
<Group gap="xs" wrap="wrap" justify="space-between">
@ -897,7 +901,7 @@ export const CommandLineArgumentsModal = ({
</Code>
))}
{arg.type && (
<Badge size="xs" variant="light" color="blue">
<Badge size="xs" variant="light" color="brand">
{arg.type}
</Badge>
)}

View file

@ -36,7 +36,7 @@ export const AccelerationSelectItem = ({
discreteDevices.length > 0 && (
<Group gap={4}>
{discreteDevices.slice(0, 2).map((device, index) => (
<Badge key={index} size="md" variant="light" color="blue">
<Badge key={index} size="md" variant="light" color="brand">
{renderDeviceName(device)}
</Badge>
))}

View file

@ -76,9 +76,10 @@ export const ModelCard = ({ modelId }: ModelCardProps) => {
}}
/>
),
img: ({ node: _, ...props }) => (
img: ({ node: _, alt, ...props }) => (
<img
{...props}
alt={alt ?? ''}
style={{
maxWidth: '100%',
height: 'auto',

View file

@ -97,7 +97,7 @@ export const ModelsTable = ({
</Table.Td>
<Table.Td>
{model.paramSize && (
<Badge size="sm" variant="light" color="blue">
<Badge size="sm" variant="light" color="brand">
{model.paramSize}
</Badge>
)}

View file

@ -136,7 +136,7 @@ export const HuggingFaceSearchModal = ({
<Stack gap="md" style={{ height: '100%' }}>
{selectedModel ? (
<Group gap="xs" wrap="nowrap">
<ActionIcon variant="subtle" onClick={handleBack}>
<ActionIcon variant="subtle" aria-label="Back to search results" onClick={handleBack}>
<ArrowLeft size={18} />
</ActionIcon>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
@ -148,7 +148,11 @@ export const HuggingFaceSearchModal = ({
</Text>
</Stack>
<Tooltip label="Open on Hugging Face">
<ActionIcon variant="subtle" onClick={handleOpenExternal}>
<ActionIcon
variant="subtle"
aria-label="Open on Hugging Face"
onClick={handleOpenExternal}
>
<ExternalLink size={16} />
</ActionIcon>
</Tooltip>

View file

@ -140,7 +140,12 @@ export const ModelFileField = ({
</Button>
{searchParams && (
<Tooltip label="Search Hugging Face">
<ActionIcon onClick={() => setSearchModalOpened(true)} variant="outline" size="lg">
<ActionIcon
onClick={() => setSearchModalOpened(true)}
variant="outline"
size="lg"
aria-label="Search Hugging Face"
>
<Search size={16} />
</ActionIcon>
</Tooltip>
@ -156,9 +161,10 @@ export const ModelFileField = ({
<ActionIcon
onClick={() => void handleAnalyzeModel()}
variant="light"
color="blue"
color="brand"
size="lg"
disabled={validationState === 'neutral' || validationState === 'invalid'}
aria-label="Analyze model"
>
<Info size={16} />
</ActionIcon>

View file

@ -425,7 +425,7 @@ export const LaunchScreen = ({ onLaunch }: LaunchScreenProps) => {
onClick={handleLaunchClick}
size="lg"
variant="filled"
color="blue"
color="brand"
style={{
fontSize: '1em',
fontWeight: 600,

View file

@ -1,17 +1,5 @@
import iconUrl from '/icon.png';
import {
Button,
Card,
Container,
Group,
Image,
List,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import { Check } from 'lucide-react';
import { Button, Container, Group, Image, List, Stack, Text, Title } from '@mantine/core';
import { PRODUCT_NAME } from '@/constants';
@ -21,77 +9,51 @@ interface WelcomeScreenProps {
export const WelcomeScreen = ({ onGetStarted }: WelcomeScreenProps) => (
<Container size="md" mt="md">
<Stack gap="xl">
<Card withBorder radius="md" shadow="sm" p="xl">
<Stack gap="lg" align="center">
<Stack gap="md" align="center">
<Group gap="md" mr="xl" align="center">
<Image src={iconUrl} alt={PRODUCT_NAME} w={36} h={36} />
<Title order={1} ta="center">
{PRODUCT_NAME}
</Title>
</Group>
<Text size="lg" c="dimmed" ta="center" maw={600}>
Run Large Language Models locally
</Text>
</Stack>
<Stack gap="lg" maw={560}>
<Stack gap="sm">
<Group gap="md" align="center">
<Image src={iconUrl} alt={PRODUCT_NAME} w={36} h={36} />
<Title order={1}>{PRODUCT_NAME}</Title>
</Group>
<Text size="lg" c="dimmed">
Run Large Language Models locally on your hardware.
</Text>
</Stack>
<Stack gap="lg" w="100%" maw={600}>
<List
spacing="sm"
size="sm"
center
icon={
<ThemeIcon color="green" size={20} radius="xl">
<Check size={12} />
</ThemeIcon>
}
>
<List.Item>
<Text>
<Text component="span" fw={500}>
Chat with AI models
</Text>{' '}
- Have conversations, ask questions, get help with writing
</Text>
</List.Item>
<List.Item>
<Text>
<Text component="span" fw={500}>
Generate images
</Text>{' '}
- Easily create artwork and illustrations with LLMs
</Text>
</List.Item>
<List.Item>
<Text>
<Text component="span" fw={500}>
Complete privacy
</Text>{' '}
- Everything runs on your computer, no data sent to servers
</Text>
</List.Item>
<List.Item>
<Text>
<Text component="span" fw={500}>
Hardware acceleration
</Text>{' '}
- Supports CUDA, ROCm and Vulkan backends
</Text>
</List.Item>
</List>
<List spacing="xs" size="sm">
<List.Item>
<Text fw={500} component="span">
Chat
</Text>{' '}
conversations, questions, writing help
</List.Item>
<List.Item>
<Text fw={500} component="span">
Image generation
</Text>{' '}
artwork and illustrations via LLMs
</List.Item>
<List.Item>
<Text fw={500} component="span">
Private
</Text>{' '}
everything runs locally, no data leaves the machine
</List.Item>
<List.Item>
<Text fw={500} component="span">
Accelerated
</Text>{' '}
CUDA, ROCm, and Vulkan backends supported
</List.Item>
</List>
<Text size="xs" c="dimmed" ta="center" mt="sm">
Hardware acceleration requires appropriate drivers to be manually installed (CUDA for
NVIDIA GPUs, ROCm for AMD GPUs, etc.)
</Text>
</Stack>
<Text size="xs" c="dimmed">
Hardware acceleration requires drivers installed separately (CUDA for NVIDIA, ROCm for AMD).
</Text>
<Button size="lg" mt="lg" onClick={onGetStarted}>
Get Started
</Button>
</Stack>
</Card>
<Button size="md" onClick={onGetStarted} style={{ alignSelf: 'flex-start' }}>
Get Started
</Button>
</Stack>
</Container>
);

View file

@ -59,7 +59,7 @@ export const AboutTab = () => {
<Text size="xl" fw={600}>
{PRODUCT_NAME}
</Text>
<Badge variant="light" color="blue" size="lg" style={{ textTransform: 'none' }}>
<Badge variant="light" color="brand" size="lg" style={{ textTransform: 'none' }}>
v{versionInfo.appVersion}
</Badge>
</Group>
@ -84,17 +84,17 @@ export const AboutTab = () => {
</Group>
</Card>
<Card withBorder radius="md" p="md">
<Text size="lg" fw={500} mb="md">
<Stack gap="xs">
<Text size="sm" fw={500} c="dimmed">
About {PRODUCT_NAME}
</Text>
<Text size="sm" c="dimmed" mb="md">
<Text size="sm" c="dimmed">
{PRODUCT_NAME} is a user-friendly desktop application that makes it easy to run large
language models locally on your machine. Whether you&apos;re looking to chat with AI
models, generate images, or explore different interfaces like SillyTavern and Open WebUI,{' '}
{PRODUCT_NAME} provides a streamlined experience for local AI.
</Text>
</Card>
</Stack>
</Stack>
);
};

View file

@ -2,6 +2,7 @@ import { Group, rem, SegmentedControl, Slider, Stack, Text, TextInput } from '@m
import type { MantineColorScheme } from '@mantine/core';
import { Monitor, Moon, Sun } from 'lucide-react';
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import { FrontendInterfaceSelector } from '@/components/settings/FrontendInterfaceSelector';
import { ZOOM } from '@/constants';
@ -13,13 +14,7 @@ interface AppearanceTabProps {
}
export const AppearanceTab = ({ isOnInterfaceScreen = false }: AppearanceTabProps) => {
const {
rawColorScheme,
resolvedColorScheme,
setColorScheme: setStoreColorScheme,
} = usePreferencesStore();
const isDark = resolvedColorScheme === 'dark';
const { rawColorScheme, setColorScheme: setStoreColorScheme } = usePreferencesStore();
const [zoomLevel, setZoomLevel] = useState(ZOOM.DEFAULT_LEVEL as number);
const [zoomPercentage, setZoomPercentage] = useState(ZOOM.DEFAULT_PERCENTAGE.toString());
@ -74,15 +69,16 @@ export const AppearanceTab = ({ isOnInterfaceScreen = false }: AppearanceTabProp
fullWidth
value={rawColorScheme}
onChange={handleColorSchemeChange}
styles={(theme) => ({
indicator: {
backgroundColor: isDark ? theme.colors.dark[5] : theme.colors.gray[2],
border: `1px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[4]}`,
},
root: {
border: `0.5px solid ${isDark ? theme.colors.dark[4] : theme.colors.gray[4]}`,
},
})}
style={
{
'--sc-indicator-color':
'light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5))',
'--sc-indicator-border':
'light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))',
border:
'0.5px solid light-dark(var(--mantine-color-gray-4), var(--mantine-color-dark-4))',
} as CSSProperties
}
data={[
{
label: (

View file

@ -244,7 +244,7 @@ export const BackendsTab = () => {
target="_blank"
rel="noopener noreferrer"
size="sm"
c="blue"
c="brand"
>
<Group gap={4} align="center">
<span>Release notes</span>

View file

@ -8,7 +8,7 @@ import {
SlidersHorizontal,
Wrench,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { Modal } from '@/components/Modal';
import { AboutTab } from '@/components/settings/AboutTab';
@ -38,23 +38,6 @@ export const SettingsModal = ({
const effectiveActiveTab = !showBackendsTab && activeTab === 'backends' ? 'general' : activeTab;
useEffect(() => {
if (opened) {
const originalOverflow = document.body.style.overflow;
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`;
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.paddingRight = '';
};
}
return undefined;
}, [opened]);
return (
<Modal
opened={opened}

View file

@ -1,18 +1,32 @@
@import '@mantine/core/styles.css';
@import '@fontsource/inter/latin-400.css';
@import '@fontsource/inter/latin-500.css';
@import '@fontsource/inter/latin-600.css';
@import '@fontsource/geist/latin-400.css';
@import '@fontsource/geist/latin-500.css';
@import '@fontsource/geist/latin-600.css';
:root {
--gerbil-window-border: light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.08));
--gerbil-scrollbar-thumb: rgba(134, 142, 150, 0.5);
--gerbil-scrollbar-thumb-hover: rgba(134, 142, 150, 0.8);
--gerbil-scrollbar-thumb-active: rgba(73, 80, 87, 0.9);
--gerbil-shadow-sm: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.3);
}
[data-mantine-color-scheme='dark'] {
--gerbil-scrollbar-thumb: rgba(173, 181, 189, 0.5);
--gerbil-scrollbar-thumb-hover: rgba(173, 181, 189, 0.8);
--gerbil-scrollbar-thumb-active: rgba(206, 212, 218, 0.9);
}
#root {
height: 100vh;
width: 100vw;
border-left: 1px solid light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.08));
border-right: 1px solid light-dark(rgba(0, 0, 0, 0.15), rgba(255, 255, 255, 0.08));
border-left: 1px solid var(--gerbil-window-border);
border-right: 1px solid var(--gerbil-window-border);
box-sizing: border-box;
}
[data-mantine-color-scheme='light'] .mantine-Select-option:hover {
background-color: #e9ecef;
background-color: var(--mantine-color-gray-2);
}
/* Custom scrollbars */
@ -27,30 +41,30 @@
}
::-webkit-scrollbar-thumb {
background: rgba(134, 142, 150, 0.5);
background: var(--gerbil-scrollbar-thumb);
border-radius: 0.25rem;
transition: all 0.2s ease;
transition: background 0.2s ease;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(134, 142, 150, 0.8);
background: var(--gerbil-scrollbar-thumb-hover);
}
::-webkit-scrollbar-thumb:active {
background: rgba(73, 80, 87, 0.9);
background: var(--gerbil-scrollbar-thumb-active);
}
/* Dark mode scrollbar support */
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
background: rgba(173, 181, 189, 0.5);
background: var(--gerbil-scrollbar-thumb);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
background: rgba(173, 181, 189, 0.8);
background: var(--gerbil-scrollbar-thumb-hover);
}
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:active {
background: rgba(206, 212, 218, 0.9);
background: var(--gerbil-scrollbar-thumb-active);
}
/* TitleBar gerbil icon animations */

View file

@ -3,7 +3,21 @@ import type { CSSVariablesResolver } from '@mantine/core';
export const theme = createTheme({
black: '#101113',
primaryColor: 'brand',
colors: {
// Steel-blue: technical, enthusiast-grade — vibrant but cooler than Mantine default
brand: [
'#eef5ff', // 0 — faint wash
'#d8eaff', // 1
'#b0d0ff', // 2
'#7aaff7', // 3
'#4890f0', // 4 — dark mode accent
'#2b72e0', // 5
'#1e5ec8', // 6 — light primary
'#184eb0', // 7
'#113d88', // 8 — dark primary
'#0c2d64', // 9 — deep
],
gray: [
'#f8f9fa',
'#f1f3f4',
@ -52,9 +66,9 @@ export const theme = createTheme({
},
},
},
fontFamily: 'Inter, sans-serif',
fontFamily: 'Geist, sans-serif',
headings: {
fontFamily: 'Inter, sans-serif',
fontFamily: 'Geist, sans-serif',
},
white: '#fafafa',
});
@ -65,14 +79,16 @@ export const cssVariablesResolver: CSSVariablesResolver = (t) => {
variables: { ...v8.variables },
dark: {
...v8.dark,
'--mantine-color-body': '#0f0f0f',
'--mantine-color-default-border': '#2a2a2a',
'--mantine-color-body': 'oklch(12% 0.008 240)',
'--mantine-color-default-border': 'oklch(22% 0.008 240)',
'--gerbil-link-color': 'var(--mantine-color-brand-4)',
},
light: {
...v8.light,
'--mantine-color-body': '#fafafa',
'--mantine-color-white': '#fafafa',
'--mantine-color-default-border': '#dee2e6',
'--gerbil-link-color': 'var(--mantine-color-brand-5)',
},
};
};

View file

@ -15,7 +15,7 @@ const linkifyText = (text: string) =>
const cleanUrl = url.replace(/[.,;:!?]+$/, '');
const trailingPunctuation = url.slice(cleanUrl.length);
return `<a href="${cleanUrl}" target="_blank" rel="noopener noreferrer" style="color: #339af0; text-decoration: underline; cursor: pointer;">${cleanUrl}</a>${trailingPunctuation}`;
return `<a href="${cleanUrl}" target="_blank" rel="noopener noreferrer" style="color: var(--gerbil-link-color, #4f8cc5); text-decoration: underline; cursor: pointer;">${cleanUrl}</a>${trailingPunctuation}`;
});
const escapeHtmlExceptLinks = (text: string) => {